Completed
Branch master (8d5465)
by
unknown
31:25
created

WikiPage::doPurge()   C

Complexity

Conditions 9
Paths 37

Size

Total Lines 51
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
eloc 27
nc 37
nop 1
dl 0
loc 51
rs 6.2727
c 0
b 0
f 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * Base representation for a MediaWiki page.
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 */
22
23
use \MediaWiki\Logger\LoggerFactory;
24
use \MediaWiki\MediaWikiServices;
25
26
/**
27
 * Class representing a MediaWiki article and history.
28
 *
29
 * Some fields are public only for backwards-compatibility. Use accessors.
30
 * In the past, this class was part of Article.php and everything was public.
31
 */
32
class WikiPage implements Page, IDBAccessObject {
33
	// Constants for $mDataLoadedFrom and related
34
35
	/**
36
	 * @var Title
37
	 */
38
	public $mTitle = null;
39
40
	/**@{{
41
	 * @protected
42
	 */
43
	public $mDataLoaded = false;         // !< Boolean
44
	public $mIsRedirect = false;         // !< Boolean
45
	public $mLatest = false;             // !< Integer (false means "not loaded")
46
	/**@}}*/
47
48
	/** @var stdClass Map of cache fields (text, parser output, ect) for a proposed/new edit */
49
	public $mPreparedEdit = false;
50
51
	/**
52
	 * @var int
53
	 */
54
	protected $mId = null;
55
56
	/**
57
	 * @var int One of the READ_* constants
58
	 */
59
	protected $mDataLoadedFrom = self::READ_NONE;
60
61
	/**
62
	 * @var Title
63
	 */
64
	protected $mRedirectTarget = null;
65
66
	/**
67
	 * @var Revision
68
	 */
69
	protected $mLastRevision = null;
70
71
	/**
72
	 * @var string Timestamp of the current revision or empty string if not loaded
73
	 */
74
	protected $mTimestamp = '';
75
76
	/**
77
	 * @var string
78
	 */
79
	protected $mTouched = '19700101000000';
80
81
	/**
82
	 * @var string
83
	 */
84
	protected $mLinksUpdated = '19700101000000';
85
86
	const PURGE_CDN_CACHE = 1; // purge CDN cache for page variant URLs
87
	const PURGE_CLUSTER_PCACHE = 2; // purge parser cache in the local datacenter
88
	const PURGE_GLOBAL_PCACHE = 4; // set page_touched to clear parser cache in all datacenters
89
	const PURGE_ALL = 7;
90
91
	/**
92
	 * Constructor and clear the article
93
	 * @param Title $title Reference to a Title object.
94
	 */
95
	public function __construct( Title $title ) {
96
		$this->mTitle = $title;
97
	}
98
99
	/**
100
	 * Makes sure that the mTitle object is cloned
101
	 * to the newly cloned WikiPage.
102
	 */
103
	public function __clone() {
104
		$this->mTitle = clone $this->mTitle;
105
	}
106
107
	/**
108
	 * Create a WikiPage object of the appropriate class for the given title.
109
	 *
110
	 * @param Title $title
111
	 *
112
	 * @throws MWException
113
	 * @return WikiPage|WikiCategoryPage|WikiFilePage
114
	 */
115
	public static function factory( Title $title ) {
116
		$ns = $title->getNamespace();
117
118
		if ( $ns == NS_MEDIA ) {
119
			throw new MWException( "NS_MEDIA is a virtual namespace; use NS_FILE." );
120
		} elseif ( $ns < 0 ) {
121
			throw new MWException( "Invalid or virtual namespace $ns given." );
122
		}
123
124
		switch ( $ns ) {
125
			case NS_FILE:
126
				$page = new WikiFilePage( $title );
127
				break;
128
			case NS_CATEGORY:
129
				$page = new WikiCategoryPage( $title );
130
				break;
131
			default:
132
				$page = new WikiPage( $title );
133
		}
134
135
		return $page;
136
	}
137
138
	/**
139
	 * Constructor from a page id
140
	 *
141
	 * @param int $id Article ID to load
142
	 * @param string|int $from One of the following values:
143
	 *        - "fromdb" or WikiPage::READ_NORMAL to select from a replica DB
144
	 *        - "fromdbmaster" or WikiPage::READ_LATEST to select from the master database
145
	 *
146
	 * @return WikiPage|null
147
	 */
148
	public static function newFromID( $id, $from = 'fromdb' ) {
149
		// page id's are never 0 or negative, see bug 61166
150
		if ( $id < 1 ) {
151
			return null;
152
		}
153
154
		$from = self::convertSelectType( $from );
155
		$db = wfGetDB( $from === self::READ_LATEST ? DB_MASTER : DB_REPLICA );
156
		$row = $db->selectRow(
157
			'page', self::selectFields(), [ 'page_id' => $id ], __METHOD__ );
158
		if ( !$row ) {
159
			return null;
160
		}
161
		return self::newFromRow( $row, $from );
0 ignored issues
show
Bug introduced by
It seems like $row defined by $db->selectRow('page', s...d' => $id), __METHOD__) on line 156 can also be of type boolean; however, WikiPage::newFromRow() does only seem to accept object, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
Bug introduced by
It seems like $from defined by self::convertSelectType($from) on line 154 can also be of type object; however, WikiPage::newFromRow() does only seem to accept string|integer, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
162
	}
163
164
	/**
165
	 * Constructor from a database row
166
	 *
167
	 * @since 1.20
168
	 * @param object $row Database row containing at least fields returned by selectFields().
169
	 * @param string|int $from Source of $data:
170
	 *        - "fromdb" or WikiPage::READ_NORMAL: from a replica DB
171
	 *        - "fromdbmaster" or WikiPage::READ_LATEST: from the master DB
172
	 *        - "forupdate" or WikiPage::READ_LOCKING: from the master DB using SELECT FOR UPDATE
173
	 * @return WikiPage
174
	 */
175
	public static function newFromRow( $row, $from = 'fromdb' ) {
176
		$page = self::factory( Title::newFromRow( $row ) );
177
		$page->loadFromRow( $row, $from );
178
		return $page;
179
	}
180
181
	/**
182
	 * Convert 'fromdb', 'fromdbmaster' and 'forupdate' to READ_* constants.
183
	 *
184
	 * @param object|string|int $type
185
	 * @return mixed
186
	 */
187
	private static function convertSelectType( $type ) {
188
		switch ( $type ) {
189
		case 'fromdb':
190
			return self::READ_NORMAL;
191
		case 'fromdbmaster':
192
			return self::READ_LATEST;
193
		case 'forupdate':
194
			return self::READ_LOCKING;
195
		default:
196
			// It may already be an integer or whatever else
197
			return $type;
198
		}
199
	}
200
201
	/**
202
	 * @todo Move this UI stuff somewhere else
203
	 *
204
	 * @see ContentHandler::getActionOverrides
205
	 */
206
	public function getActionOverrides() {
207
		return $this->getContentHandler()->getActionOverrides();
208
	}
209
210
	/**
211
	 * Returns the ContentHandler instance to be used to deal with the content of this WikiPage.
212
	 *
213
	 * Shorthand for ContentHandler::getForModelID( $this->getContentModel() );
214
	 *
215
	 * @return ContentHandler
216
	 *
217
	 * @since 1.21
218
	 */
219
	public function getContentHandler() {
220
		return ContentHandler::getForModelID( $this->getContentModel() );
221
	}
222
223
	/**
224
	 * Get the title object of the article
225
	 * @return Title Title object of this page
226
	 */
227
	public function getTitle() {
228
		return $this->mTitle;
229
	}
230
231
	/**
232
	 * Clear the object
233
	 * @return void
234
	 */
235
	public function clear() {
236
		$this->mDataLoaded = false;
237
		$this->mDataLoadedFrom = self::READ_NONE;
238
239
		$this->clearCacheFields();
240
	}
241
242
	/**
243
	 * Clear the object cache fields
244
	 * @return void
245
	 */
246
	protected function clearCacheFields() {
247
		$this->mId = null;
248
		$this->mRedirectTarget = null; // Title object if set
249
		$this->mLastRevision = null; // Latest revision
250
		$this->mTouched = '19700101000000';
251
		$this->mLinksUpdated = '19700101000000';
252
		$this->mTimestamp = '';
253
		$this->mIsRedirect = false;
254
		$this->mLatest = false;
255
		// Bug 57026: do not clear mPreparedEdit since prepareTextForEdit() already checks
256
		// the requested rev ID and content against the cached one for equality. For most
257
		// content types, the output should not change during the lifetime of this cache.
258
		// Clearing it can cause extra parses on edit for no reason.
259
	}
260
261
	/**
262
	 * Clear the mPreparedEdit cache field, as may be needed by mutable content types
263
	 * @return void
264
	 * @since 1.23
265
	 */
266
	public function clearPreparedEdit() {
267
		$this->mPreparedEdit = false;
0 ignored issues
show
Documentation Bug introduced by
It seems like false of type false is incompatible with the declared type object<stdClass> of property $mPreparedEdit.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
268
	}
269
270
	/**
271
	 * Return the list of revision fields that should be selected to create
272
	 * a new page.
273
	 *
274
	 * @return array
275
	 */
276
	public static function selectFields() {
277
		global $wgContentHandlerUseDB, $wgPageLanguageUseDB;
278
279
		$fields = [
280
			'page_id',
281
			'page_namespace',
282
			'page_title',
283
			'page_restrictions',
284
			'page_is_redirect',
285
			'page_is_new',
286
			'page_random',
287
			'page_touched',
288
			'page_links_updated',
289
			'page_latest',
290
			'page_len',
291
		];
292
293
		if ( $wgContentHandlerUseDB ) {
294
			$fields[] = 'page_content_model';
295
		}
296
297
		if ( $wgPageLanguageUseDB ) {
298
			$fields[] = 'page_lang';
299
		}
300
301
		return $fields;
302
	}
303
304
	/**
305
	 * Fetch a page record with the given conditions
306
	 * @param IDatabase $dbr
307
	 * @param array $conditions
308
	 * @param array $options
309
	 * @return object|bool Database result resource, or false on failure
310
	 */
311
	protected function pageData( $dbr, $conditions, $options = [] ) {
312
		$fields = self::selectFields();
313
314
		Hooks::run( 'ArticlePageDataBefore', [ &$this, &$fields ] );
315
316
		$row = $dbr->selectRow( 'page', $fields, $conditions, __METHOD__, $options );
317
318
		Hooks::run( 'ArticlePageDataAfter', [ &$this, &$row ] );
319
320
		return $row;
321
	}
322
323
	/**
324
	 * Fetch a page record matching the Title object's namespace and title
325
	 * using a sanitized title string
326
	 *
327
	 * @param IDatabase $dbr
328
	 * @param Title $title
329
	 * @param array $options
330
	 * @return object|bool Database result resource, or false on failure
331
	 */
332
	public function pageDataFromTitle( $dbr, $title, $options = [] ) {
333
		return $this->pageData( $dbr, [
334
			'page_namespace' => $title->getNamespace(),
335
			'page_title' => $title->getDBkey() ], $options );
336
	}
337
338
	/**
339
	 * Fetch a page record matching the requested ID
340
	 *
341
	 * @param IDatabase $dbr
342
	 * @param int $id
343
	 * @param array $options
344
	 * @return object|bool Database result resource, or false on failure
345
	 */
346
	public function pageDataFromId( $dbr, $id, $options = [] ) {
347
		return $this->pageData( $dbr, [ 'page_id' => $id ], $options );
348
	}
349
350
	/**
351
	 * Load the object from a given source by title
352
	 *
353
	 * @param object|string|int $from One of the following:
354
	 *   - A DB query result object.
355
	 *   - "fromdb" or WikiPage::READ_NORMAL to get from a replica DB.
356
	 *   - "fromdbmaster" or WikiPage::READ_LATEST to get from the master DB.
357
	 *   - "forupdate"  or WikiPage::READ_LOCKING to get from the master DB
358
	 *     using SELECT FOR UPDATE.
359
	 *
360
	 * @return void
361
	 */
362
	public function loadPageData( $from = 'fromdb' ) {
363
		$from = self::convertSelectType( $from );
364
		if ( is_int( $from ) && $from <= $this->mDataLoadedFrom ) {
365
			// We already have the data from the correct location, no need to load it twice.
366
			return;
367
		}
368
369
		if ( is_int( $from ) ) {
370
			list( $index, $opts ) = DBAccessObjectUtils::getDBOptions( $from );
371
			$data = $this->pageDataFromTitle( wfGetDB( $index ), $this->mTitle, $opts );
372
373
			if ( !$data
374
				&& $index == DB_REPLICA
375
				&& wfGetLB()->getServerCount() > 1
0 ignored issues
show
Deprecated Code introduced by
The function wfGetLB() has been deprecated with message: since 1.27, use MediaWikiServices::getDBLoadBalancer() or MediaWikiServices::getDBLoadBalancerFactory() instead.

This function has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed from the class and what other function to use instead.

Loading history...
376
				&& wfGetLB()->hasOrMadeRecentMasterChanges()
0 ignored issues
show
Deprecated Code introduced by
The function wfGetLB() has been deprecated with message: since 1.27, use MediaWikiServices::getDBLoadBalancer() or MediaWikiServices::getDBLoadBalancerFactory() instead.

This function has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed from the class and what other function to use instead.

Loading history...
377
			) {
378
				$from = self::READ_LATEST;
379
				list( $index, $opts ) = DBAccessObjectUtils::getDBOptions( $from );
380
				$data = $this->pageDataFromTitle( wfGetDB( $index ), $this->mTitle, $opts );
381
			}
382
		} else {
383
			// No idea from where the caller got this data, assume replica DB.
384
			$data = $from;
385
			$from = self::READ_NORMAL;
386
		}
387
388
		$this->loadFromRow( $data, $from );
0 ignored issues
show
Bug introduced by
It seems like $data defined by $from on line 384 can also be of type string; however, WikiPage::loadFromRow() does only seem to accept object|boolean, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
389
	}
390
391
	/**
392
	 * Load the object from a database row
393
	 *
394
	 * @since 1.20
395
	 * @param object|bool $data DB row containing fields returned by selectFields() or false
396
	 * @param string|int $from One of the following:
397
	 *        - "fromdb" or WikiPage::READ_NORMAL if the data comes from a replica DB
398
	 *        - "fromdbmaster" or WikiPage::READ_LATEST if the data comes from the master DB
399
	 *        - "forupdate"  or WikiPage::READ_LOCKING if the data comes from
400
	 *          the master DB using SELECT FOR UPDATE
401
	 */
402
	public function loadFromRow( $data, $from ) {
403
		$lc = LinkCache::singleton();
404
		$lc->clearLink( $this->mTitle );
405
406
		if ( $data ) {
407
			$lc->addGoodLinkObjFromRow( $this->mTitle, $data );
408
409
			$this->mTitle->loadFromRow( $data );
0 ignored issues
show
Bug introduced by
It seems like $data defined by parameter $data on line 402 can also be of type object; however, Title::loadFromRow() does only seem to accept object<stdClass>|boolean, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
410
411
			// Old-fashioned restrictions
412
			$this->mTitle->loadRestrictions( $data->page_restrictions );
413
414
			$this->mId = intval( $data->page_id );
415
			$this->mTouched = wfTimestamp( TS_MW, $data->page_touched );
0 ignored issues
show
Documentation Bug introduced by
It seems like wfTimestamp(TS_MW, $data->page_touched) can also be of type false. However, the property $mTouched is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
416
			$this->mLinksUpdated = wfTimestampOrNull( TS_MW, $data->page_links_updated );
0 ignored issues
show
Documentation Bug introduced by
It seems like wfTimestampOrNull(TS_MW,...ta->page_links_updated) can also be of type false. However, the property $mLinksUpdated is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
417
			$this->mIsRedirect = intval( $data->page_is_redirect );
0 ignored issues
show
Documentation Bug introduced by
The property $mIsRedirect was declared of type boolean, but intval($data->page_is_redirect) is of type integer. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
418
			$this->mLatest = intval( $data->page_latest );
0 ignored issues
show
Documentation Bug introduced by
The property $mLatest was declared of type boolean, but intval($data->page_latest) is of type integer. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
419
			// Bug 37225: $latest may no longer match the cached latest Revision object.
420
			// Double-check the ID of any cached latest Revision object for consistency.
421
			if ( $this->mLastRevision && $this->mLastRevision->getId() != $this->mLatest ) {
422
				$this->mLastRevision = null;
423
				$this->mTimestamp = '';
424
			}
425
		} else {
426
			$lc->addBadLinkObj( $this->mTitle );
427
428
			$this->mTitle->loadFromRow( false );
429
430
			$this->clearCacheFields();
431
432
			$this->mId = 0;
433
		}
434
435
		$this->mDataLoaded = true;
436
		$this->mDataLoadedFrom = self::convertSelectType( $from );
0 ignored issues
show
Documentation Bug introduced by
It seems like self::convertSelectType($from) can also be of type object or string. However, the property $mDataLoadedFrom is declared as type integer. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
437
	}
438
439
	/**
440
	 * @return int Page ID
441
	 */
442
	public function getId() {
443
		if ( !$this->mDataLoaded ) {
444
			$this->loadPageData();
445
		}
446
		return $this->mId;
447
	}
448
449
	/**
450
	 * @return bool Whether or not the page exists in the database
451
	 */
452
	public function exists() {
453
		if ( !$this->mDataLoaded ) {
454
			$this->loadPageData();
455
		}
456
		return $this->mId > 0;
457
	}
458
459
	/**
460
	 * Check if this page is something we're going to be showing
461
	 * some sort of sensible content for. If we return false, page
462
	 * views (plain action=view) will return an HTTP 404 response,
463
	 * so spiders and robots can know they're following a bad link.
464
	 *
465
	 * @return bool
466
	 */
467
	public function hasViewableContent() {
468
		return $this->mTitle->isKnown();
469
	}
470
471
	/**
472
	 * Tests if the article content represents a redirect
473
	 *
474
	 * @return bool
475
	 */
476
	public function isRedirect() {
477
		if ( !$this->mDataLoaded ) {
478
			$this->loadPageData();
479
		}
480
481
		return (bool)$this->mIsRedirect;
482
	}
483
484
	/**
485
	 * Returns the page's content model id (see the CONTENT_MODEL_XXX constants).
486
	 *
487
	 * Will use the revisions actual content model if the page exists,
488
	 * and the page's default if the page doesn't exist yet.
489
	 *
490
	 * @return string
491
	 *
492
	 * @since 1.21
493
	 */
494
	public function getContentModel() {
495
		if ( $this->exists() ) {
496
			// look at the revision's actual content model
497
			$rev = $this->getRevision();
498
499
			if ( $rev !== null ) {
500
				return $rev->getContentModel();
501
			} else {
502
				$title = $this->mTitle->getPrefixedDBkey();
503
				wfWarn( "Page $title exists but has no (visible) revisions!" );
504
			}
505
		}
506
507
		// use the default model for this page
508
		return $this->mTitle->getContentModel();
509
	}
510
511
	/**
512
	 * Loads page_touched and returns a value indicating if it should be used
513
	 * @return bool True if this page exists and is not a redirect
514
	 */
515
	public function checkTouched() {
516
		if ( !$this->mDataLoaded ) {
517
			$this->loadPageData();
518
		}
519
		return ( $this->mId && !$this->mIsRedirect );
520
	}
521
522
	/**
523
	 * Get the page_touched field
524
	 * @return string Containing GMT timestamp
525
	 */
526
	public function getTouched() {
527
		if ( !$this->mDataLoaded ) {
528
			$this->loadPageData();
529
		}
530
		return $this->mTouched;
531
	}
532
533
	/**
534
	 * Get the page_links_updated field
535
	 * @return string|null Containing GMT timestamp
536
	 */
537
	public function getLinksTimestamp() {
538
		if ( !$this->mDataLoaded ) {
539
			$this->loadPageData();
540
		}
541
		return $this->mLinksUpdated;
542
	}
543
544
	/**
545
	 * Get the page_latest field
546
	 * @return int The rev_id of current revision
547
	 */
548
	public function getLatest() {
549
		if ( !$this->mDataLoaded ) {
550
			$this->loadPageData();
551
		}
552
		return (int)$this->mLatest;
553
	}
554
555
	/**
556
	 * Get the Revision object of the oldest revision
557
	 * @return Revision|null
558
	 */
559
	public function getOldestRevision() {
560
561
		// Try using the replica DB first, then try the master
562
		$continue = 2;
563
		$db = wfGetDB( DB_REPLICA );
564
		$revSelectFields = Revision::selectFields();
565
566
		$row = null;
567
		while ( $continue ) {
568
			$row = $db->selectRow(
569
				[ 'page', 'revision' ],
570
				$revSelectFields,
571
				[
572
					'page_namespace' => $this->mTitle->getNamespace(),
573
					'page_title' => $this->mTitle->getDBkey(),
574
					'rev_page = page_id'
575
				],
576
				__METHOD__,
577
				[
578
					'ORDER BY' => 'rev_timestamp ASC'
579
				]
580
			);
581
582
			if ( $row ) {
583
				$continue = 0;
584
			} else {
585
				$db = wfGetDB( DB_MASTER );
586
				$continue--;
587
			}
588
		}
589
590
		return $row ? Revision::newFromRow( $row ) : null;
0 ignored issues
show
Bug introduced by
It seems like $row can also be of type boolean; however, Revision::newFromRow() does only seem to accept object, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
591
	}
592
593
	/**
594
	 * Loads everything except the text
595
	 * This isn't necessary for all uses, so it's only done if needed.
596
	 */
597
	protected function loadLastEdit() {
598
		if ( $this->mLastRevision !== null ) {
599
			return; // already loaded
600
		}
601
602
		$latest = $this->getLatest();
603
		if ( !$latest ) {
604
			return; // page doesn't exist or is missing page_latest info
605
		}
606
607
		if ( $this->mDataLoadedFrom == self::READ_LOCKING ) {
608
			// Bug 37225: if session S1 loads the page row FOR UPDATE, the result always
609
			// includes the latest changes committed. This is true even within REPEATABLE-READ
610
			// transactions, where S1 normally only sees changes committed before the first S1
611
			// SELECT. Thus we need S1 to also gets the revision row FOR UPDATE; otherwise, it
612
			// may not find it since a page row UPDATE and revision row INSERT by S2 may have
613
			// happened after the first S1 SELECT.
614
			// http://dev.mysql.com/doc/refman/5.0/en/set-transaction.html#isolevel_repeatable-read
615
			$flags = Revision::READ_LOCKING;
616
		} elseif ( $this->mDataLoadedFrom == self::READ_LATEST ) {
617
			// Bug T93976: if page_latest was loaded from the master, fetch the
618
			// revision from there as well, as it may not exist yet on a replica DB.
619
			// Also, this keeps the queries in the same REPEATABLE-READ snapshot.
620
			$flags = Revision::READ_LATEST;
621
		} else {
622
			$flags = 0;
623
		}
624
		$revision = Revision::newFromPageId( $this->getId(), $latest, $flags );
625
		if ( $revision ) { // sanity
626
			$this->setLastEdit( $revision );
627
		}
628
	}
629
630
	/**
631
	 * Set the latest revision
632
	 * @param Revision $revision
633
	 */
634
	protected function setLastEdit( Revision $revision ) {
635
		$this->mLastRevision = $revision;
636
		$this->mTimestamp = $revision->getTimestamp();
0 ignored issues
show
Documentation Bug introduced by
It seems like $revision->getTimestamp() can also be of type false. However, the property $mTimestamp is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
637
	}
638
639
	/**
640
	 * Get the latest revision
641
	 * @return Revision|null
642
	 */
643
	public function getRevision() {
644
		$this->loadLastEdit();
645
		if ( $this->mLastRevision ) {
646
			return $this->mLastRevision;
647
		}
648
		return null;
649
	}
650
651
	/**
652
	 * Get the content of the current revision. No side-effects...
653
	 *
654
	 * @param int $audience One of:
655
	 *   Revision::FOR_PUBLIC       to be displayed to all users
656
	 *   Revision::FOR_THIS_USER    to be displayed to $wgUser
657
	 *   Revision::RAW              get the text regardless of permissions
658
	 * @param User $user User object to check for, only if FOR_THIS_USER is passed
659
	 *   to the $audience parameter
660
	 * @return Content|null The content of the current revision
661
	 *
662
	 * @since 1.21
663
	 */
664 View Code Duplication
	public function getContent( $audience = Revision::FOR_PUBLIC, User $user = null ) {
665
		$this->loadLastEdit();
666
		if ( $this->mLastRevision ) {
667
			return $this->mLastRevision->getContent( $audience, $user );
668
		}
669
		return null;
670
	}
671
672
	/**
673
	 * Get the text of the current revision. No side-effects...
674
	 *
675
	 * @param int $audience One of:
676
	 *   Revision::FOR_PUBLIC       to be displayed to all users
677
	 *   Revision::FOR_THIS_USER    to be displayed to the given user
678
	 *   Revision::RAW              get the text regardless of permissions
679
	 * @param User $user User object to check for, only if FOR_THIS_USER is passed
680
	 *   to the $audience parameter
681
	 * @return string|bool The text of the current revision
682
	 * @deprecated since 1.21, getContent() should be used instead.
683
	 */
684
	public function getText( $audience = Revision::FOR_PUBLIC, User $user = null ) {
685
		ContentHandler::deprecated( __METHOD__, '1.21' );
686
687
		$this->loadLastEdit();
688
		if ( $this->mLastRevision ) {
689
			return $this->mLastRevision->getText( $audience, $user );
690
		}
691
		return false;
692
	}
693
694
	/**
695
	 * @return string MW timestamp of last article revision
696
	 */
697
	public function getTimestamp() {
698
		// Check if the field has been filled by WikiPage::setTimestamp()
699
		if ( !$this->mTimestamp ) {
700
			$this->loadLastEdit();
701
		}
702
703
		return wfTimestamp( TS_MW, $this->mTimestamp );
704
	}
705
706
	/**
707
	 * Set the page timestamp (use only to avoid DB queries)
708
	 * @param string $ts MW timestamp of last article revision
709
	 * @return void
710
	 */
711
	public function setTimestamp( $ts ) {
712
		$this->mTimestamp = wfTimestamp( TS_MW, $ts );
0 ignored issues
show
Documentation Bug introduced by
It seems like wfTimestamp(TS_MW, $ts) can also be of type false. However, the property $mTimestamp is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
713
	}
714
715
	/**
716
	 * @param int $audience One of:
717
	 *   Revision::FOR_PUBLIC       to be displayed to all users
718
	 *   Revision::FOR_THIS_USER    to be displayed to the given user
719
	 *   Revision::RAW              get the text regardless of permissions
720
	 * @param User $user User object to check for, only if FOR_THIS_USER is passed
721
	 *   to the $audience parameter
722
	 * @return int User ID for the user that made the last article revision
723
	 */
724 View Code Duplication
	public function getUser( $audience = Revision::FOR_PUBLIC, User $user = null ) {
725
		$this->loadLastEdit();
726
		if ( $this->mLastRevision ) {
727
			return $this->mLastRevision->getUser( $audience, $user );
728
		} else {
729
			return -1;
730
		}
731
	}
732
733
	/**
734
	 * Get the User object of the user who created the page
735
	 * @param int $audience One of:
736
	 *   Revision::FOR_PUBLIC       to be displayed to all users
737
	 *   Revision::FOR_THIS_USER    to be displayed to the given user
738
	 *   Revision::RAW              get the text regardless of permissions
739
	 * @param User $user User object to check for, only if FOR_THIS_USER is passed
740
	 *   to the $audience parameter
741
	 * @return User|null
742
	 */
743
	public function getCreator( $audience = Revision::FOR_PUBLIC, User $user = null ) {
744
		$revision = $this->getOldestRevision();
745
		if ( $revision ) {
746
			$userName = $revision->getUserText( $audience, $user );
747
			return User::newFromName( $userName, false );
0 ignored issues
show
Bug introduced by
It seems like $userName defined by $revision->getUserText($audience, $user) on line 746 can also be of type boolean; however, User::newFromName() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
748
		} else {
749
			return null;
750
		}
751
	}
752
753
	/**
754
	 * @param int $audience One of:
755
	 *   Revision::FOR_PUBLIC       to be displayed to all users
756
	 *   Revision::FOR_THIS_USER    to be displayed to the given user
757
	 *   Revision::RAW              get the text regardless of permissions
758
	 * @param User $user User object to check for, only if FOR_THIS_USER is passed
759
	 *   to the $audience parameter
760
	 * @return string Username of the user that made the last article revision
761
	 */
762 View Code Duplication
	public function getUserText( $audience = Revision::FOR_PUBLIC, User $user = null ) {
763
		$this->loadLastEdit();
764
		if ( $this->mLastRevision ) {
765
			return $this->mLastRevision->getUserText( $audience, $user );
766
		} else {
767
			return '';
768
		}
769
	}
770
771
	/**
772
	 * @param int $audience One of:
773
	 *   Revision::FOR_PUBLIC       to be displayed to all users
774
	 *   Revision::FOR_THIS_USER    to be displayed to the given user
775
	 *   Revision::RAW              get the text regardless of permissions
776
	 * @param User $user User object to check for, only if FOR_THIS_USER is passed
777
	 *   to the $audience parameter
778
	 * @return string Comment stored for the last article revision
779
	 */
780 View Code Duplication
	public function getComment( $audience = Revision::FOR_PUBLIC, User $user = null ) {
781
		$this->loadLastEdit();
782
		if ( $this->mLastRevision ) {
783
			return $this->mLastRevision->getComment( $audience, $user );
784
		} else {
785
			return '';
786
		}
787
	}
788
789
	/**
790
	 * Returns true if last revision was marked as "minor edit"
791
	 *
792
	 * @return bool Minor edit indicator for the last article revision.
793
	 */
794
	public function getMinorEdit() {
795
		$this->loadLastEdit();
796
		if ( $this->mLastRevision ) {
797
			return $this->mLastRevision->isMinor();
798
		} else {
799
			return false;
800
		}
801
	}
802
803
	/**
804
	 * Determine whether a page would be suitable for being counted as an
805
	 * article in the site_stats table based on the title & its content
806
	 *
807
	 * @param object|bool $editInfo (false): object returned by prepareTextForEdit(),
808
	 *   if false, the current database state will be used
809
	 * @return bool
810
	 */
811
	public function isCountable( $editInfo = false ) {
812
		global $wgArticleCountMethod;
813
814
		if ( !$this->mTitle->isContentPage() ) {
815
			return false;
816
		}
817
818
		if ( $editInfo ) {
819
			$content = $editInfo->pstContent;
820
		} else {
821
			$content = $this->getContent();
822
		}
823
824
		if ( !$content || $content->isRedirect() ) {
825
			return false;
826
		}
827
828
		$hasLinks = null;
829
830
		if ( $wgArticleCountMethod === 'link' ) {
831
			// nasty special case to avoid re-parsing to detect links
832
833
			if ( $editInfo ) {
834
				// ParserOutput::getLinks() is a 2D array of page links, so
835
				// to be really correct we would need to recurse in the array
836
				// but the main array should only have items in it if there are
837
				// links.
838
				$hasLinks = (bool)count( $editInfo->output->getLinks() );
839
			} else {
840
				$hasLinks = (bool)wfGetDB( DB_REPLICA )->selectField( 'pagelinks', 1,
841
					[ 'pl_from' => $this->getId() ], __METHOD__ );
842
			}
843
		}
844
845
		return $content->isCountable( $hasLinks );
846
	}
847
848
	/**
849
	 * If this page is a redirect, get its target
850
	 *
851
	 * The target will be fetched from the redirect table if possible.
852
	 * If this page doesn't have an entry there, call insertRedirect()
853
	 * @return Title|null Title object, or null if this page is not a redirect
854
	 */
855
	public function getRedirectTarget() {
856
		if ( !$this->mTitle->isRedirect() ) {
857
			return null;
858
		}
859
860
		if ( $this->mRedirectTarget !== null ) {
861
			return $this->mRedirectTarget;
862
		}
863
864
		// Query the redirect table
865
		$dbr = wfGetDB( DB_REPLICA );
866
		$row = $dbr->selectRow( 'redirect',
867
			[ 'rd_namespace', 'rd_title', 'rd_fragment', 'rd_interwiki' ],
868
			[ 'rd_from' => $this->getId() ],
869
			__METHOD__
870
		);
871
872
		// rd_fragment and rd_interwiki were added later, populate them if empty
873
		if ( $row && !is_null( $row->rd_fragment ) && !is_null( $row->rd_interwiki ) ) {
874
			$this->mRedirectTarget = Title::makeTitle(
875
				$row->rd_namespace, $row->rd_title,
876
				$row->rd_fragment, $row->rd_interwiki
877
			);
878
			return $this->mRedirectTarget;
879
		}
880
881
		// This page doesn't have an entry in the redirect table
882
		$this->mRedirectTarget = $this->insertRedirect();
883
		return $this->mRedirectTarget;
884
	}
885
886
	/**
887
	 * Insert an entry for this page into the redirect table if the content is a redirect
888
	 *
889
	 * The database update will be deferred via DeferredUpdates
890
	 *
891
	 * Don't call this function directly unless you know what you're doing.
892
	 * @return Title|null Title object or null if not a redirect
893
	 */
894
	public function insertRedirect() {
895
		$content = $this->getContent();
896
		$retval = $content ? $content->getUltimateRedirectTarget() : null;
897
		if ( !$retval ) {
898
			return null;
899
		}
900
901
		// Update the DB post-send if the page has not cached since now
902
		$that = $this;
903
		$latest = $this->getLatest();
904
		DeferredUpdates::addCallableUpdate(
905
			function () use ( $that, $retval, $latest ) {
906
				$that->insertRedirectEntry( $retval, $latest );
907
			},
908
			DeferredUpdates::POSTSEND,
909
			wfGetDB( DB_MASTER )
910
		);
911
912
		return $retval;
913
	}
914
915
	/**
916
	 * Insert or update the redirect table entry for this page to indicate it redirects to $rt
917
	 * @param Title $rt Redirect target
918
	 * @param int|null $oldLatest Prior page_latest for check and set
919
	 */
920
	public function insertRedirectEntry( Title $rt, $oldLatest = null ) {
921
		$dbw = wfGetDB( DB_MASTER );
922
		$dbw->startAtomic( __METHOD__ );
923
924
		if ( !$oldLatest || $oldLatest == $this->lockAndGetLatest() ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $oldLatest of type integer|null is loosely compared to false; this is ambiguous if the integer can be zero. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
925
			$dbw->replace( 'redirect',
926
				[ 'rd_from' ],
927
				[
928
					'rd_from' => $this->getId(),
929
					'rd_namespace' => $rt->getNamespace(),
930
					'rd_title' => $rt->getDBkey(),
931
					'rd_fragment' => $rt->getFragment(),
932
					'rd_interwiki' => $rt->getInterwiki(),
933
				],
934
				__METHOD__
935
			);
936
		}
937
938
		$dbw->endAtomic( __METHOD__ );
939
	}
940
941
	/**
942
	 * Get the Title object or URL this page redirects to
943
	 *
944
	 * @return bool|Title|string False, Title of in-wiki target, or string with URL
945
	 */
946
	public function followRedirect() {
947
		return $this->getRedirectURL( $this->getRedirectTarget() );
0 ignored issues
show
Bug introduced by
It seems like $this->getRedirectTarget() can be null; however, getRedirectURL() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
948
	}
949
950
	/**
951
	 * Get the Title object or URL to use for a redirect. We use Title
952
	 * objects for same-wiki, non-special redirects and URLs for everything
953
	 * else.
954
	 * @param Title $rt Redirect target
955
	 * @return bool|Title|string False, Title object of local target, or string with URL
956
	 */
957
	public function getRedirectURL( $rt ) {
958
		if ( !$rt ) {
959
			return false;
960
		}
961
962
		if ( $rt->isExternal() ) {
963
			if ( $rt->isLocal() ) {
964
				// Offsite wikis need an HTTP redirect.
965
				// This can be hard to reverse and may produce loops,
966
				// so they may be disabled in the site configuration.
967
				$source = $this->mTitle->getFullURL( 'redirect=no' );
968
				return $rt->getFullURL( [ 'rdfrom' => $source ] );
969
			} else {
970
				// External pages without "local" bit set are not valid
971
				// redirect targets
972
				return false;
973
			}
974
		}
975
976
		if ( $rt->isSpecialPage() ) {
977
			// Gotta handle redirects to special pages differently:
978
			// Fill the HTTP response "Location" header and ignore the rest of the page we're on.
979
			// Some pages are not valid targets.
980
			if ( $rt->isValidRedirectTarget() ) {
981
				return $rt->getFullURL();
982
			} else {
983
				return false;
984
			}
985
		}
986
987
		return $rt;
988
	}
989
990
	/**
991
	 * Get a list of users who have edited this article, not including the user who made
992
	 * the most recent revision, which you can get from $article->getUser() if you want it
993
	 * @return UserArrayFromResult
994
	 */
995
	public function getContributors() {
996
		// @todo FIXME: This is expensive; cache this info somewhere.
997
998
		$dbr = wfGetDB( DB_REPLICA );
999
1000
		if ( $dbr->implicitGroupby() ) {
1001
			$realNameField = 'user_real_name';
1002
		} else {
1003
			$realNameField = 'MIN(user_real_name) AS user_real_name';
1004
		}
1005
1006
		$tables = [ 'revision', 'user' ];
1007
1008
		$fields = [
1009
			'user_id' => 'rev_user',
1010
			'user_name' => 'rev_user_text',
1011
			$realNameField,
1012
			'timestamp' => 'MAX(rev_timestamp)',
1013
		];
1014
1015
		$conds = [ 'rev_page' => $this->getId() ];
1016
1017
		// The user who made the top revision gets credited as "this page was last edited by
1018
		// John, based on contributions by Tom, Dick and Harry", so don't include them twice.
1019
		$user = $this->getUser();
1020
		if ( $user ) {
1021
			$conds[] = "rev_user != $user";
1022
		} else {
1023
			$conds[] = "rev_user_text != {$dbr->addQuotes( $this->getUserText() )}";
0 ignored issues
show
Bug introduced by
It seems like $this->getUserText() targeting WikiPage::getUserText() can also be of type boolean; however, DatabaseBase::addQuotes() does only seem to accept string|object<Blob>, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
1024
		}
1025
1026
		// Username hidden?
1027
		$conds[] = "{$dbr->bitAnd( 'rev_deleted', Revision::DELETED_USER )} = 0";
1028
1029
		$jconds = [
1030
			'user' => [ 'LEFT JOIN', 'rev_user = user_id' ],
1031
		];
1032
1033
		$options = [
1034
			'GROUP BY' => [ 'rev_user', 'rev_user_text' ],
1035
			'ORDER BY' => 'timestamp DESC',
1036
		];
1037
1038
		$res = $dbr->select( $tables, $fields, $conds, __METHOD__, $options, $jconds );
1039
		return new UserArrayFromResult( $res );
0 ignored issues
show
Bug introduced by
It seems like $res defined by $dbr->select($tables, $f...D__, $options, $jconds) on line 1038 can also be of type boolean; however, UserArrayFromResult::__construct() does only seem to accept object<ResultWrapper>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
1040
	}
1041
1042
	/**
1043
	 * Should the parser cache be used?
1044
	 *
1045
	 * @param ParserOptions $parserOptions ParserOptions to check
1046
	 * @param int $oldId
1047
	 * @return bool
1048
	 */
1049
	public function shouldCheckParserCache( ParserOptions $parserOptions, $oldId ) {
1050
		return $parserOptions->getStubThreshold() == 0
1051
			&& $this->exists()
1052
			&& ( $oldId === null || $oldId === 0 || $oldId === $this->getLatest() )
1053
			&& $this->getContentHandler()->isParserCacheSupported();
1054
	}
1055
1056
	/**
1057
	 * Get a ParserOutput for the given ParserOptions and revision ID.
1058
	 *
1059
	 * The parser cache will be used if possible. Cache misses that result
1060
	 * in parser runs are debounced with PoolCounter.
1061
	 *
1062
	 * @since 1.19
1063
	 * @param ParserOptions $parserOptions ParserOptions to use for the parse operation
1064
	 * @param null|int      $oldid Revision ID to get the text from, passing null or 0 will
1065
	 *                             get the current revision (default value)
1066
	 * @param bool          $forceParse Force reindexing, regardless of cache settings
1067
	 * @return bool|ParserOutput ParserOutput or false if the revision was not found
1068
	 */
1069
	public function getParserOutput(
1070
		ParserOptions $parserOptions, $oldid = null, $forceParse = false
1071
	) {
1072
		$useParserCache =
1073
			( !$forceParse ) && $this->shouldCheckParserCache( $parserOptions, $oldid );
1074
		wfDebug( __METHOD__ .
1075
			': using parser cache: ' . ( $useParserCache ? 'yes' : 'no' ) . "\n" );
1076
		if ( $parserOptions->getStubThreshold() ) {
1077
			wfIncrStats( 'pcache.miss.stub' );
1078
		}
1079
1080
		if ( $useParserCache ) {
1081
			$parserOutput = ParserCache::singleton()->get( $this, $parserOptions );
1082
			if ( $parserOutput !== false ) {
1083
				return $parserOutput;
1084
			}
1085
		}
1086
1087
		if ( $oldid === null || $oldid === 0 ) {
1088
			$oldid = $this->getLatest();
1089
		}
1090
1091
		$pool = new PoolWorkArticleView( $this, $parserOptions, $oldid, $useParserCache );
1092
		$pool->execute();
1093
1094
		return $pool->getParserOutput();
1095
	}
1096
1097
	/**
1098
	 * Do standard deferred updates after page view (existing or missing page)
1099
	 * @param User $user The relevant user
1100
	 * @param int $oldid Revision id being viewed; if not given or 0, latest revision is assumed
1101
	 */
1102
	public function doViewUpdates( User $user, $oldid = 0 ) {
1103
		if ( wfReadOnly() ) {
1104
			return;
1105
		}
1106
1107
		Hooks::run( 'PageViewUpdates', [ $this, $user ] );
1108
		// Update newtalk / watchlist notification status
1109
		try {
1110
			$user->clearNotification( $this->mTitle, $oldid );
1111
		} catch ( DBError $e ) {
1112
			// Avoid outage if the master is not reachable
1113
			MWExceptionHandler::logException( $e );
1114
		}
1115
	}
1116
1117
	/**
1118
	 * Perform the actions of a page purging
1119
	 * @param integer $flags Bitfield of WikiPage::PURGE_* constants
1120
	 * @return bool
1121
	 */
1122
	public function doPurge( $flags = self::PURGE_ALL ) {
1123
		if ( !Hooks::run( 'ArticlePurge', [ &$this ] ) ) {
1124
			return false;
1125
		}
1126
1127
		if ( ( $flags & self::PURGE_GLOBAL_PCACHE ) == self::PURGE_GLOBAL_PCACHE ) {
1128
			// Set page_touched in the database to invalidate all DC caches
1129
			$this->mTitle->invalidateCache();
1130
		} elseif ( ( $flags & self::PURGE_CLUSTER_PCACHE ) == self::PURGE_CLUSTER_PCACHE ) {
1131
			// Delete the parser options key in the local cluster to invalidate the DC cache
1132
			ParserCache::singleton()->deleteOptionsKey( $this );
1133
			// Avoid sending HTTP 304s in ViewAction to the client who just issued the purge
1134
			$cache = ObjectCache::getLocalClusterInstance();
1135
			$cache->set(
1136
				$cache->makeKey( 'page', 'last-dc-purge', $this->getId() ),
1137
				wfTimestamp( TS_MW ),
1138
				$cache::TTL_HOUR
1139
			);
1140
		}
1141
1142
		if ( ( $flags & self::PURGE_CDN_CACHE ) == self::PURGE_CDN_CACHE ) {
1143
			// Clear any HTML file cache
1144
			HTMLFileCache::clearFileCache( $this->getTitle() );
1145
			// Send purge after any page_touched above update was committed
1146
			DeferredUpdates::addUpdate(
1147
				new CdnCacheUpdate( $this->mTitle->getCdnUrls() ),
1148
				DeferredUpdates::PRESEND
1149
			);
1150
		}
1151
1152
		if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) {
1153
			// @todo move this logic to MessageCache
1154
			if ( $this->exists() ) {
1155
				// NOTE: use transclusion text for messages.
1156
				//       This is consistent with  MessageCache::getMsgFromNamespace()
1157
1158
				$content = $this->getContent();
1159
				$text = $content === null ? null : $content->getWikitextForTransclusion();
1160
1161
				if ( $text === null ) {
1162
					$text = false;
1163
				}
1164
			} else {
1165
				$text = false;
1166
			}
1167
1168
			MessageCache::singleton()->replace( $this->mTitle->getDBkey(), $text );
1169
		}
1170
1171
		return true;
1172
	}
1173
1174
	/**
1175
	 * Get the last time a user explicitly purged the page via action=purge
1176
	 *
1177
	 * @return string|bool TS_MW timestamp or false
1178
	 * @since 1.28
1179
	 */
1180
	public function getLastPurgeTimestamp() {
1181
		$cache = ObjectCache::getLocalClusterInstance();
1182
1183
		return $cache->get( $cache->makeKey( 'page', 'last-dc-purge', $this->getId() ) );
1184
	}
1185
1186
	/**
1187
	 * Insert a new empty page record for this article.
1188
	 * This *must* be followed up by creating a revision
1189
	 * and running $this->updateRevisionOn( ... );
1190
	 * or else the record will be left in a funky state.
1191
	 * Best if all done inside a transaction.
1192
	 *
1193
	 * @param IDatabase $dbw
1194
	 * @param int|null $pageId Custom page ID that will be used for the insert statement
1195
	 *
1196
	 * @return bool|int The newly created page_id key; false if the row was not
1197
	 *   inserted, e.g. because the title already existed or because the specified
1198
	 *   page ID is already in use.
1199
	 */
1200
	public function insertOn( $dbw, $pageId = null ) {
1201
		$pageIdForInsert = $pageId ?: $dbw->nextSequenceValue( 'page_page_id_seq' );
1202
		$dbw->insert(
1203
			'page',
1204
			[
1205
				'page_id'           => $pageIdForInsert,
1206
				'page_namespace'    => $this->mTitle->getNamespace(),
1207
				'page_title'        => $this->mTitle->getDBkey(),
1208
				'page_restrictions' => '',
1209
				'page_is_redirect'  => 0, // Will set this shortly...
1210
				'page_is_new'       => 1,
1211
				'page_random'       => wfRandom(),
1212
				'page_touched'      => $dbw->timestamp(),
1213
				'page_latest'       => 0, // Fill this in shortly...
1214
				'page_len'          => 0, // Fill this in shortly...
1215
			],
1216
			__METHOD__,
1217
			'IGNORE'
1218
		);
1219
1220
		if ( $dbw->affectedRows() > 0 ) {
1221
			$newid = $pageId ?: $dbw->insertId();
1222
			$this->mId = $newid;
1223
			$this->mTitle->resetArticleID( $newid );
1224
1225
			return $newid;
1226
		} else {
1227
			return false; // nothing changed
1228
		}
1229
	}
1230
1231
	/**
1232
	 * Update the page record to point to a newly saved revision.
1233
	 *
1234
	 * @param IDatabase $dbw
1235
	 * @param Revision $revision For ID number, and text used to set
1236
	 *   length and redirect status fields
1237
	 * @param int $lastRevision If given, will not overwrite the page field
1238
	 *   when different from the currently set value.
1239
	 *   Giving 0 indicates the new page flag should be set on.
1240
	 * @param bool $lastRevIsRedirect If given, will optimize adding and
1241
	 *   removing rows in redirect table.
1242
	 * @return bool Success; false if the page row was missing or page_latest changed
1243
	 */
1244
	public function updateRevisionOn( $dbw, $revision, $lastRevision = null,
1245
		$lastRevIsRedirect = null
1246
	) {
1247
		global $wgContentHandlerUseDB;
1248
1249
		// Assertion to try to catch T92046
1250
		if ( (int)$revision->getId() === 0 ) {
1251
			throw new InvalidArgumentException(
1252
				__METHOD__ . ': Revision has ID ' . var_export( $revision->getId(), 1 )
1253
			);
1254
		}
1255
1256
		$content = $revision->getContent();
1257
		$len = $content ? $content->getSize() : 0;
1258
		$rt = $content ? $content->getUltimateRedirectTarget() : null;
1259
1260
		$conditions = [ 'page_id' => $this->getId() ];
1261
1262
		if ( !is_null( $lastRevision ) ) {
1263
			// An extra check against threads stepping on each other
1264
			$conditions['page_latest'] = $lastRevision;
1265
		}
1266
1267
		$row = [ /* SET */
1268
			'page_latest'      => $revision->getId(),
1269
			'page_touched'     => $dbw->timestamp( $revision->getTimestamp() ),
1270
			'page_is_new'      => ( $lastRevision === 0 ) ? 1 : 0,
1271
			'page_is_redirect' => $rt !== null ? 1 : 0,
1272
			'page_len'         => $len,
1273
		];
1274
1275
		if ( $wgContentHandlerUseDB ) {
1276
			$row['page_content_model'] = $revision->getContentModel();
1277
		}
1278
1279
		$dbw->update( 'page',
1280
			$row,
1281
			$conditions,
1282
			__METHOD__ );
1283
1284
		$result = $dbw->affectedRows() > 0;
1285
		if ( $result ) {
1286
			$this->updateRedirectOn( $dbw, $rt, $lastRevIsRedirect );
0 ignored issues
show
Bug introduced by
It seems like $rt defined by $content ? $content->get...RedirectTarget() : null on line 1258 can be null; however, WikiPage::updateRedirectOn() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1287
			$this->setLastEdit( $revision );
1288
			$this->mLatest = $revision->getId();
0 ignored issues
show
Documentation Bug introduced by
It seems like $revision->getId() can also be of type integer. However, the property $mLatest is declared as type boolean. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
1289
			$this->mIsRedirect = (bool)$rt;
1290
			// Update the LinkCache.
1291
			LinkCache::singleton()->addGoodLinkObj(
1292
				$this->getId(),
1293
				$this->mTitle,
1294
				$len,
1295
				$this->mIsRedirect,
1296
				$this->mLatest,
1297
				$revision->getContentModel()
1298
			);
1299
		}
1300
1301
		return $result;
1302
	}
1303
1304
	/**
1305
	 * Add row to the redirect table if this is a redirect, remove otherwise.
1306
	 *
1307
	 * @param IDatabase $dbw
1308
	 * @param Title $redirectTitle Title object pointing to the redirect target,
1309
	 *   or NULL if this is not a redirect
1310
	 * @param null|bool $lastRevIsRedirect If given, will optimize adding and
1311
	 *   removing rows in redirect table.
1312
	 * @return bool True on success, false on failure
1313
	 * @private
1314
	 */
1315
	public function updateRedirectOn( $dbw, $redirectTitle, $lastRevIsRedirect = null ) {
1316
		// Always update redirects (target link might have changed)
1317
		// Update/Insert if we don't know if the last revision was a redirect or not
1318
		// Delete if changing from redirect to non-redirect
1319
		$isRedirect = !is_null( $redirectTitle );
1320
1321
		if ( !$isRedirect && $lastRevIsRedirect === false ) {
1322
			return true;
1323
		}
1324
1325
		if ( $isRedirect ) {
1326
			$this->insertRedirectEntry( $redirectTitle );
1327
		} else {
1328
			// This is not a redirect, remove row from redirect table
1329
			$where = [ 'rd_from' => $this->getId() ];
1330
			$dbw->delete( 'redirect', $where, __METHOD__ );
1331
		}
1332
1333
		if ( $this->getTitle()->getNamespace() == NS_FILE ) {
1334
			RepoGroup::singleton()->getLocalRepo()->invalidateImageRedirect( $this->getTitle() );
1335
		}
1336
1337
		return ( $dbw->affectedRows() != 0 );
1338
	}
1339
1340
	/**
1341
	 * If the given revision is newer than the currently set page_latest,
1342
	 * update the page record. Otherwise, do nothing.
1343
	 *
1344
	 * @deprecated since 1.24, use updateRevisionOn instead
1345
	 *
1346
	 * @param IDatabase $dbw
1347
	 * @param Revision $revision
1348
	 * @return bool
1349
	 */
1350
	public function updateIfNewerOn( $dbw, $revision ) {
1351
1352
		$row = $dbw->selectRow(
1353
			[ 'revision', 'page' ],
1354
			[ 'rev_id', 'rev_timestamp', 'page_is_redirect' ],
1355
			[
1356
				'page_id' => $this->getId(),
1357
				'page_latest=rev_id' ],
1358
			__METHOD__ );
1359
1360
		if ( $row ) {
1361
			if ( wfTimestamp( TS_MW, $row->rev_timestamp ) >= $revision->getTimestamp() ) {
1362
				return false;
1363
			}
1364
			$prev = $row->rev_id;
1365
			$lastRevIsRedirect = (bool)$row->page_is_redirect;
1366
		} else {
1367
			// No or missing previous revision; mark the page as new
1368
			$prev = 0;
1369
			$lastRevIsRedirect = null;
1370
		}
1371
1372
		$ret = $this->updateRevisionOn( $dbw, $revision, $prev, $lastRevIsRedirect );
1373
1374
		return $ret;
1375
	}
1376
1377
	/**
1378
	 * Get the content that needs to be saved in order to undo all revisions
1379
	 * between $undo and $undoafter. Revisions must belong to the same page,
1380
	 * must exist and must not be deleted
1381
	 * @param Revision $undo
1382
	 * @param Revision $undoafter Must be an earlier revision than $undo
1383
	 * @return Content|bool Content on success, false on failure
1384
	 * @since 1.21
1385
	 * Before we had the Content object, this was done in getUndoText
1386
	 */
1387
	public function getUndoContent( Revision $undo, Revision $undoafter = null ) {
1388
		$handler = $undo->getContentHandler();
1389
		return $handler->getUndoContent( $this->getRevision(), $undo, $undoafter );
0 ignored issues
show
Bug introduced by
It seems like $this->getRevision() can be null; however, getUndoContent() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
Bug introduced by
It seems like $undoafter defined by parameter $undoafter on line 1387 can be null; however, ContentHandler::getUndoContent() does not accept null, maybe add an additional type check?

It seems like you allow that null is being passed for a parameter, however the function which is called does not seem to accept null.

We recommend to add an additional type check (or disallow null for the parameter):

function notNullable(stdClass $x) { }

// Unsafe
function withoutCheck(stdClass $x = null) {
    notNullable($x);
}

// Safe - Alternative 1: Adding Additional Type-Check
function withCheck(stdClass $x = null) {
    if ($x instanceof stdClass) {
        notNullable($x);
    }
}

// Safe - Alternative 2: Changing Parameter
function withNonNullableParam(stdClass $x) {
    notNullable($x);
}
Loading history...
1390
	}
1391
1392
	/**
1393
	 * Returns true if this page's content model supports sections.
1394
	 *
1395
	 * @return bool
1396
	 *
1397
	 * @todo The skin should check this and not offer section functionality if
1398
	 *   sections are not supported.
1399
	 * @todo The EditPage should check this and not offer section functionality
1400
	 *   if sections are not supported.
1401
	 */
1402
	public function supportsSections() {
1403
		return $this->getContentHandler()->supportsSections();
1404
	}
1405
1406
	/**
1407
	 * @param string|number|null|bool $sectionId Section identifier as a number or string
1408
	 * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page
1409
	 * or 'new' for a new section.
1410
	 * @param Content $sectionContent New content of the section.
1411
	 * @param string $sectionTitle New section's subject, only if $section is "new".
1412
	 * @param string $edittime Revision timestamp or null to use the current revision.
1413
	 *
1414
	 * @throws MWException
1415
	 * @return Content|null New complete article content, or null if error.
1416
	 *
1417
	 * @since 1.21
1418
	 * @deprecated since 1.24, use replaceSectionAtRev instead
1419
	 */
1420
	public function replaceSectionContent(
1421
		$sectionId, Content $sectionContent, $sectionTitle = '', $edittime = null
1422
	) {
1423
1424
		$baseRevId = null;
1425
		if ( $edittime && $sectionId !== 'new' ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $edittime of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1426
			$dbr = wfGetDB( DB_REPLICA );
1427
			$rev = Revision::loadFromTimestamp( $dbr, $this->mTitle, $edittime );
1428
			// Try the master if this thread may have just added it.
1429
			// This could be abstracted into a Revision method, but we don't want
1430
			// to encourage loading of revisions by timestamp.
1431
			if ( !$rev
1432
				&& wfGetLB()->getServerCount() > 1
0 ignored issues
show
Deprecated Code introduced by
The function wfGetLB() has been deprecated with message: since 1.27, use MediaWikiServices::getDBLoadBalancer() or MediaWikiServices::getDBLoadBalancerFactory() instead.

This function has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed from the class and what other function to use instead.

Loading history...
1433
				&& wfGetLB()->hasOrMadeRecentMasterChanges()
0 ignored issues
show
Deprecated Code introduced by
The function wfGetLB() has been deprecated with message: since 1.27, use MediaWikiServices::getDBLoadBalancer() or MediaWikiServices::getDBLoadBalancerFactory() instead.

This function has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed from the class and what other function to use instead.

Loading history...
1434
			) {
1435
				$dbw = wfGetDB( DB_MASTER );
1436
				$rev = Revision::loadFromTimestamp( $dbw, $this->mTitle, $edittime );
1437
			}
1438
			if ( $rev ) {
1439
				$baseRevId = $rev->getId();
1440
			}
1441
		}
1442
1443
		return $this->replaceSectionAtRev( $sectionId, $sectionContent, $sectionTitle, $baseRevId );
1444
	}
1445
1446
	/**
1447
	 * @param string|number|null|bool $sectionId Section identifier as a number or string
1448
	 * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page
1449
	 * or 'new' for a new section.
1450
	 * @param Content $sectionContent New content of the section.
1451
	 * @param string $sectionTitle New section's subject, only if $section is "new".
1452
	 * @param int|null $baseRevId
1453
	 *
1454
	 * @throws MWException
1455
	 * @return Content|null New complete article content, or null if error.
1456
	 *
1457
	 * @since 1.24
1458
	 */
1459
	public function replaceSectionAtRev( $sectionId, Content $sectionContent,
1460
		$sectionTitle = '', $baseRevId = null
1461
	) {
1462
1463
		if ( strval( $sectionId ) === '' ) {
1464
			// Whole-page edit; let the whole text through
1465
			$newContent = $sectionContent;
1466
		} else {
1467
			if ( !$this->supportsSections() ) {
1468
				throw new MWException( "sections not supported for content model " .
1469
					$this->getContentHandler()->getModelID() );
1470
			}
1471
1472
			// Bug 30711: always use current version when adding a new section
1473
			if ( is_null( $baseRevId ) || $sectionId === 'new' ) {
1474
				$oldContent = $this->getContent();
1475
			} else {
1476
				$rev = Revision::newFromId( $baseRevId );
1477
				if ( !$rev ) {
1478
					wfDebug( __METHOD__ . " asked for bogus section (page: " .
1479
						$this->getId() . "; section: $sectionId)\n" );
1480
					return null;
1481
				}
1482
1483
				$oldContent = $rev->getContent();
1484
			}
1485
1486
			if ( !$oldContent ) {
1487
				wfDebug( __METHOD__ . ": no page text\n" );
1488
				return null;
1489
			}
1490
1491
			$newContent = $oldContent->replaceSection( $sectionId, $sectionContent, $sectionTitle );
1492
		}
1493
1494
		return $newContent;
1495
	}
1496
1497
	/**
1498
	 * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
1499
	 * @param int $flags
1500
	 * @return int Updated $flags
1501
	 */
1502
	public function checkFlags( $flags ) {
1503
		if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) {
1504
			if ( $this->exists() ) {
1505
				$flags |= EDIT_UPDATE;
1506
			} else {
1507
				$flags |= EDIT_NEW;
1508
			}
1509
		}
1510
1511
		return $flags;
1512
	}
1513
1514
	/**
1515
	 * Change an existing article or create a new article. Updates RC and all necessary caches,
1516
	 * optionally via the deferred update array.
1517
	 *
1518
	 * @param string $text New text
1519
	 * @param string $summary Edit summary
1520
	 * @param int $flags Bitfield:
1521
	 *      EDIT_NEW
1522
	 *          Article is known or assumed to be non-existent, create a new one
1523
	 *      EDIT_UPDATE
1524
	 *          Article is known or assumed to be pre-existing, update it
1525
	 *      EDIT_MINOR
1526
	 *          Mark this edit minor, if the user is allowed to do so
1527
	 *      EDIT_SUPPRESS_RC
1528
	 *          Do not log the change in recentchanges
1529
	 *      EDIT_FORCE_BOT
1530
	 *          Mark the edit a "bot" edit regardless of user rights
1531
	 *      EDIT_AUTOSUMMARY
1532
	 *          Fill in blank summaries with generated text where possible
1533
	 *      EDIT_INTERNAL
1534
	 *          Signal that the page retrieve/save cycle happened entirely in this request.
1535
	 *
1536
	 * If neither EDIT_NEW nor EDIT_UPDATE is specified, the status of the
1537
	 * article will be detected. If EDIT_UPDATE is specified and the article
1538
	 * doesn't exist, the function will return an edit-gone-missing error. If
1539
	 * EDIT_NEW is specified and the article does exist, an edit-already-exists
1540
	 * error will be returned. These two conditions are also possible with
1541
	 * auto-detection due to MediaWiki's performance-optimised locking strategy.
1542
	 *
1543
	 * @param bool|int $baseRevId The revision ID this edit was based off, if any.
1544
	 *   This is not the parent revision ID, rather the revision ID for older
1545
	 *   content used as the source for a rollback, for example.
1546
	 * @param User $user The user doing the edit
1547
	 *
1548
	 * @throws MWException
1549
	 * @return Status Possible errors:
1550
	 *   edit-hook-aborted: The ArticleSave hook aborted the edit but didn't
1551
	 *     set the fatal flag of $status
1552
	 *   edit-gone-missing: In update mode, but the article didn't exist.
1553
	 *   edit-conflict: In update mode, the article changed unexpectedly.
1554
	 *   edit-no-change: Warning that the text was the same as before.
1555
	 *   edit-already-exists: In creation mode, but the article already exists.
1556
	 *
1557
	 * Extensions may define additional errors.
1558
	 *
1559
	 * $return->value will contain an associative array with members as follows:
1560
	 *     new: Boolean indicating if the function attempted to create a new article.
1561
	 *     revision: The revision object for the inserted revision, or null.
1562
	 *
1563
	 * Compatibility note: this function previously returned a boolean value
1564
	 * indicating success/failure
1565
	 *
1566
	 * @deprecated since 1.21: use doEditContent() instead.
1567
	 */
1568
	public function doEdit( $text, $summary, $flags = 0, $baseRevId = false, $user = null ) {
1569
		ContentHandler::deprecated( __METHOD__, '1.21' );
1570
1571
		$content = ContentHandler::makeContent( $text, $this->getTitle() );
1572
1573
		return $this->doEditContent( $content, $summary, $flags, $baseRevId, $user );
1574
	}
1575
1576
	/**
1577
	 * Change an existing article or create a new article. Updates RC and all necessary caches,
1578
	 * optionally via the deferred update array.
1579
	 *
1580
	 * @param Content $content New content
1581
	 * @param string $summary Edit summary
1582
	 * @param int $flags Bitfield:
1583
	 *      EDIT_NEW
1584
	 *          Article is known or assumed to be non-existent, create a new one
1585
	 *      EDIT_UPDATE
1586
	 *          Article is known or assumed to be pre-existing, update it
1587
	 *      EDIT_MINOR
1588
	 *          Mark this edit minor, if the user is allowed to do so
1589
	 *      EDIT_SUPPRESS_RC
1590
	 *          Do not log the change in recentchanges
1591
	 *      EDIT_FORCE_BOT
1592
	 *          Mark the edit a "bot" edit regardless of user rights
1593
	 *      EDIT_AUTOSUMMARY
1594
	 *          Fill in blank summaries with generated text where possible
1595
	 *      EDIT_INTERNAL
1596
	 *          Signal that the page retrieve/save cycle happened entirely in this request.
1597
	 *
1598
	 * If neither EDIT_NEW nor EDIT_UPDATE is specified, the status of the
1599
	 * article will be detected. If EDIT_UPDATE is specified and the article
1600
	 * doesn't exist, the function will return an edit-gone-missing error. If
1601
	 * EDIT_NEW is specified and the article does exist, an edit-already-exists
1602
	 * error will be returned. These two conditions are also possible with
1603
	 * auto-detection due to MediaWiki's performance-optimised locking strategy.
1604
	 *
1605
	 * @param bool|int $baseRevId The revision ID this edit was based off, if any.
1606
	 *   This is not the parent revision ID, rather the revision ID for older
1607
	 *   content used as the source for a rollback, for example.
1608
	 * @param User $user The user doing the edit
1609
	 * @param string $serialFormat Format for storing the content in the
1610
	 *   database.
1611
	 * @param array|null $tags Change tags to apply to this edit
1612
	 * Callers are responsible for permission checks
1613
	 * (with ChangeTags::canAddTagsAccompanyingChange)
1614
	 *
1615
	 * @throws MWException
1616
	 * @return Status Possible errors:
1617
	 *     edit-hook-aborted: The ArticleSave hook aborted the edit but didn't
1618
	 *       set the fatal flag of $status.
1619
	 *     edit-gone-missing: In update mode, but the article didn't exist.
1620
	 *     edit-conflict: In update mode, the article changed unexpectedly.
1621
	 *     edit-no-change: Warning that the text was the same as before.
1622
	 *     edit-already-exists: In creation mode, but the article already exists.
1623
	 *
1624
	 *  Extensions may define additional errors.
1625
	 *
1626
	 *  $return->value will contain an associative array with members as follows:
1627
	 *     new: Boolean indicating if the function attempted to create a new article.
1628
	 *     revision: The revision object for the inserted revision, or null.
1629
	 *
1630
	 * @since 1.21
1631
	 * @throws MWException
1632
	 */
1633
	public function doEditContent(
1634
		Content $content, $summary, $flags = 0, $baseRevId = false,
1635
		User $user = null, $serialFormat = null, $tags = []
1636
	) {
1637
		global $wgUser, $wgUseAutomaticEditSummaries;
1638
1639
		// Old default parameter for $tags was null
1640
		if ( $tags === null ) {
1641
			$tags = [];
1642
		}
1643
1644
		// Low-level sanity check
1645
		if ( $this->mTitle->getText() === '' ) {
1646
			throw new MWException( 'Something is trying to edit an article with an empty title' );
1647
		}
1648
		// Make sure the given content type is allowed for this page
1649
		if ( !$content->getContentHandler()->canBeUsedOn( $this->mTitle ) ) {
1650
			return Status::newFatal( 'content-not-allowed-here',
1651
				ContentHandler::getLocalizedName( $content->getModel() ),
1652
				$this->mTitle->getPrefixedText()
1653
			);
1654
		}
1655
1656
		// Load the data from the master database if needed.
1657
		// The caller may already loaded it from the master or even loaded it using
1658
		// SELECT FOR UPDATE, so do not override that using clear().
1659
		$this->loadPageData( 'fromdbmaster' );
1660
1661
		$user = $user ?: $wgUser;
1662
		$flags = $this->checkFlags( $flags );
1663
1664
		// Trigger pre-save hook (using provided edit summary)
1665
		$hookStatus = Status::newGood( [] );
1666
		$hook_args = [ &$this, &$user, &$content, &$summary,
1667
							$flags & EDIT_MINOR, null, null, &$flags, &$hookStatus ];
1668
		// Check if the hook rejected the attempted save
1669
		if ( !Hooks::run( 'PageContentSave', $hook_args )
1670
			|| !ContentHandler::runLegacyHooks( 'ArticleSave', $hook_args )
1671
		) {
1672
			if ( $hookStatus->isOK() ) {
1673
				// Hook returned false but didn't call fatal(); use generic message
1674
				$hookStatus->fatal( 'edit-hook-aborted' );
1675
			}
1676
1677
			return $hookStatus;
1678
		}
1679
1680
		$old_revision = $this->getRevision(); // current revision
1681
		$old_content = $this->getContent( Revision::RAW ); // current revision's content
1682
1683
		if ( $old_content && $old_content->getModel() !== $content->getModel() ) {
1684
			$tags[] = 'mw-contentmodelchange';
1685
		}
1686
1687
		// Provide autosummaries if one is not provided and autosummaries are enabled
1688
		if ( $wgUseAutomaticEditSummaries && ( $flags & EDIT_AUTOSUMMARY ) && $summary == '' ) {
1689
			$handler = $content->getContentHandler();
1690
			$summary = $handler->getAutosummary( $old_content, $content, $flags );
1691
		}
1692
1693
		// Avoid statsd noise and wasted cycles check the edit stash (T136678)
1694
		if ( ( $flags & EDIT_INTERNAL ) || ( $flags & EDIT_FORCE_BOT ) ) {
1695
			$useCache = false;
1696
		} else {
1697
			$useCache = true;
1698
		}
1699
1700
		// Get the pre-save transform content and final parser output
1701
		$editInfo = $this->prepareContentForEdit( $content, null, $user, $serialFormat, $useCache );
1702
		$pstContent = $editInfo->pstContent; // Content object
1703
		$meta = [
1704
			'bot' => ( $flags & EDIT_FORCE_BOT ),
1705
			'minor' => ( $flags & EDIT_MINOR ) && $user->isAllowed( 'minoredit' ),
1706
			'serialized' => $editInfo->pst,
1707
			'serialFormat' => $serialFormat,
1708
			'baseRevId' => $baseRevId,
1709
			'oldRevision' => $old_revision,
1710
			'oldContent' => $old_content,
1711
			'oldId' => $this->getLatest(),
1712
			'oldIsRedirect' => $this->isRedirect(),
1713
			'oldCountable' => $this->isCountable(),
1714
			'tags' => ( $tags !== null ) ? (array)$tags : []
1715
		];
1716
1717
		// Actually create the revision and create/update the page
1718
		if ( $flags & EDIT_UPDATE ) {
1719
			$status = $this->doModify( $pstContent, $flags, $user, $summary, $meta );
1720
		} else {
1721
			$status = $this->doCreate( $pstContent, $flags, $user, $summary, $meta );
1722
		}
1723
1724
		// Promote user to any groups they meet the criteria for
1725
		DeferredUpdates::addCallableUpdate( function () use ( $user ) {
1726
			$user->addAutopromoteOnceGroups( 'onEdit' );
1727
			$user->addAutopromoteOnceGroups( 'onView' ); // b/c
1728
		} );
1729
1730
		return $status;
1731
	}
1732
1733
	/**
1734
	 * @param Content $content Pre-save transform content
1735
	 * @param integer $flags
1736
	 * @param User $user
1737
	 * @param string $summary
1738
	 * @param array $meta
1739
	 * @return Status
1740
	 * @throws DBUnexpectedError
1741
	 * @throws Exception
1742
	 * @throws FatalError
1743
	 * @throws MWException
1744
	 */
1745
	private function doModify(
1746
		Content $content, $flags, User $user, $summary, array $meta
1747
	) {
1748
		global $wgUseRCPatrol;
1749
1750
		// Update article, but only if changed.
1751
		$status = Status::newGood( [ 'new' => false, 'revision' => null ] );
1752
1753
		// Convenience variables
1754
		$now = wfTimestampNow();
1755
		$oldid = $meta['oldId'];
1756
		/** @var $oldContent Content|null */
1757
		$oldContent = $meta['oldContent'];
1758
		$newsize = $content->getSize();
1759
1760
		if ( !$oldid ) {
1761
			// Article gone missing
1762
			$status->fatal( 'edit-gone-missing' );
1763
1764
			return $status;
1765
		} elseif ( !$oldContent ) {
1766
			// Sanity check for bug 37225
1767
			throw new MWException( "Could not find text for current revision {$oldid}." );
1768
		}
1769
1770
		// @TODO: pass content object?!
1771
		$revision = new Revision( [
1772
			'page'       => $this->getId(),
1773
			'title'      => $this->mTitle, // for determining the default content model
1774
			'comment'    => $summary,
1775
			'minor_edit' => $meta['minor'],
1776
			'text'       => $meta['serialized'],
1777
			'len'        => $newsize,
1778
			'parent_id'  => $oldid,
1779
			'user'       => $user->getId(),
1780
			'user_text'  => $user->getName(),
1781
			'timestamp'  => $now,
1782
			'content_model' => $content->getModel(),
1783
			'content_format' => $meta['serialFormat'],
1784
		] );
1785
1786
		$changed = !$content->equals( $oldContent );
1787
1788
		$dbw = wfGetDB( DB_MASTER );
1789
1790
		if ( $changed ) {
1791
			$prepStatus = $content->prepareSave( $this, $flags, $oldid, $user );
1792
			$status->merge( $prepStatus );
1793
			if ( !$status->isOK() ) {
1794
				return $status;
1795
			}
1796
1797
			$dbw->startAtomic( __METHOD__ );
1798
			// Get the latest page_latest value while locking it.
1799
			// Do a CAS style check to see if it's the same as when this method
1800
			// started. If it changed then bail out before touching the DB.
1801
			$latestNow = $this->lockAndGetLatest();
1802
			if ( $latestNow != $oldid ) {
1803
				$dbw->endAtomic( __METHOD__ );
1804
				// Page updated or deleted in the mean time
1805
				$status->fatal( 'edit-conflict' );
1806
1807
				return $status;
1808
			}
1809
1810
			// At this point we are now comitted to returning an OK
1811
			// status unless some DB query error or other exception comes up.
1812
			// This way callers don't have to call rollback() if $status is bad
1813
			// unless they actually try to catch exceptions (which is rare).
1814
1815
			// Save the revision text
1816
			$revisionId = $revision->insertOn( $dbw );
1817
			// Update page_latest and friends to reflect the new revision
1818
			if ( !$this->updateRevisionOn( $dbw, $revision, null, $meta['oldIsRedirect'] ) ) {
1819
				throw new MWException( "Failed to update page row to use new revision." );
1820
			}
1821
1822
			Hooks::run( 'NewRevisionFromEditComplete',
1823
				[ $this, $revision, $meta['baseRevId'], $user ] );
1824
1825
			// Update recentchanges
1826
			if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
1827
				// Mark as patrolled if the user can do so
1828
				$patrolled = $wgUseRCPatrol && !count(
1829
						$this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) );
1830
				// Add RC row to the DB
1831
				RecentChange::notifyEdit(
1832
					$now,
0 ignored issues
show
Security Bug introduced by
It seems like $now defined by wfTimestampNow() on line 1754 can also be of type false; however, RecentChange::notifyEdit() does only seem to accept string, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
1833
					$this->mTitle,
1834
					$revision->isMinor(),
1835
					$user,
1836
					$summary,
1837
					$oldid,
1838
					$this->getTimestamp(),
0 ignored issues
show
Security Bug introduced by
It seems like $this->getTimestamp() targeting WikiPage::getTimestamp() can also be of type false; however, RecentChange::notifyEdit() does only seem to accept string, did you maybe forget to handle an error condition?
Loading history...
1839
					$meta['bot'],
1840
					'',
1841
					$oldContent ? $oldContent->getSize() : 0,
1842
					$newsize,
1843
					$revisionId,
1844
					$patrolled,
1845
					$meta['tags']
1846
				);
1847
			}
1848
1849
			$user->incEditCount();
1850
1851
			$dbw->endAtomic( __METHOD__ );
1852
			$this->mTimestamp = $now;
0 ignored issues
show
Documentation Bug introduced by
It seems like $now can also be of type false. However, the property $mTimestamp is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
1853
		} else {
1854
			// Bug 32948: revision ID must be set to page {{REVISIONID}} and
1855
			// related variables correctly. Likewise for {{REVISIONUSER}} (T135261).
1856
			$revision->setId( $this->getLatest() );
1857
			$revision->setUserIdAndName(
1858
				$this->getUser( Revision::RAW ),
1859
				$this->getUserText( Revision::RAW )
0 ignored issues
show
Bug introduced by
It seems like $this->getUserText(\Revision::RAW) targeting WikiPage::getUserText() can also be of type boolean; however, Revision::setUserIdAndName() does only seem to accept string, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
1860
			);
1861
		}
1862
1863
		if ( $changed ) {
1864
			// Return the new revision to the caller
1865
			$status->value['revision'] = $revision;
1866
		} else {
1867
			$status->warning( 'edit-no-change' );
1868
			// Update page_touched as updateRevisionOn() was not called.
1869
			// Other cache updates are managed in onArticleEdit() via doEditUpdates().
1870
			$this->mTitle->invalidateCache( $now );
0 ignored issues
show
Security Bug introduced by
It seems like $now defined by wfTimestampNow() on line 1754 can also be of type false; however, Title::invalidateCache() does only seem to accept string|null, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
1871
		}
1872
1873
		// Do secondary updates once the main changes have been committed...
1874
		DeferredUpdates::addUpdate(
1875
			new AtomicSectionUpdate(
1876
				$dbw,
1877
				__METHOD__,
1878
				function () use (
1879
					$revision, &$user, $content, $summary, &$flags,
1880
					$changed, $meta, &$status
1881
				) {
1882
					// Update links tables, site stats, etc.
1883
					$this->doEditUpdates(
1884
						$revision,
1885
						$user,
1886
						[
1887
							'changed' => $changed,
1888
							'oldcountable' => $meta['oldCountable'],
1889
							'oldrevision' => $meta['oldRevision']
1890
						]
1891
					);
1892
					// Trigger post-save hook
1893
					$params = [ &$this, &$user, $content, $summary, $flags & EDIT_MINOR,
1894
						null, null, &$flags, $revision, &$status, $meta['baseRevId'] ];
1895
					ContentHandler::runLegacyHooks( 'ArticleSaveComplete', $params );
1896
					Hooks::run( 'PageContentSaveComplete', $params );
1897
				}
1898
			),
1899
			DeferredUpdates::PRESEND
1900
		);
1901
1902
		return $status;
1903
	}
1904
1905
	/**
1906
	 * @param Content $content Pre-save transform content
1907
	 * @param integer $flags
1908
	 * @param User $user
1909
	 * @param string $summary
1910
	 * @param array $meta
1911
	 * @return Status
1912
	 * @throws DBUnexpectedError
1913
	 * @throws Exception
1914
	 * @throws FatalError
1915
	 * @throws MWException
1916
	 */
1917
	private function doCreate(
1918
		Content $content, $flags, User $user, $summary, array $meta
1919
	) {
1920
		global $wgUseRCPatrol, $wgUseNPPatrol;
1921
1922
		$status = Status::newGood( [ 'new' => true, 'revision' => null ] );
1923
1924
		$now = wfTimestampNow();
1925
		$newsize = $content->getSize();
1926
		$prepStatus = $content->prepareSave( $this, $flags, $meta['oldId'], $user );
1927
		$status->merge( $prepStatus );
1928
		if ( !$status->isOK() ) {
1929
			return $status;
1930
		}
1931
1932
		$dbw = wfGetDB( DB_MASTER );
1933
		$dbw->startAtomic( __METHOD__ );
1934
1935
		// Add the page record unless one already exists for the title
1936
		$newid = $this->insertOn( $dbw );
1937
		if ( $newid === false ) {
1938
			$dbw->endAtomic( __METHOD__ ); // nothing inserted
1939
			$status->fatal( 'edit-already-exists' );
1940
1941
			return $status; // nothing done
1942
		}
1943
1944
		// At this point we are now comitted to returning an OK
1945
		// status unless some DB query error or other exception comes up.
1946
		// This way callers don't have to call rollback() if $status is bad
1947
		// unless they actually try to catch exceptions (which is rare).
1948
1949
		// @TODO: pass content object?!
1950
		$revision = new Revision( [
1951
			'page'       => $newid,
1952
			'title'      => $this->mTitle, // for determining the default content model
1953
			'comment'    => $summary,
1954
			'minor_edit' => $meta['minor'],
1955
			'text'       => $meta['serialized'],
1956
			'len'        => $newsize,
1957
			'user'       => $user->getId(),
1958
			'user_text'  => $user->getName(),
1959
			'timestamp'  => $now,
1960
			'content_model' => $content->getModel(),
1961
			'content_format' => $meta['serialFormat'],
1962
		] );
1963
1964
		// Save the revision text...
1965
		$revisionId = $revision->insertOn( $dbw );
1966
		// Update the page record with revision data
1967
		if ( !$this->updateRevisionOn( $dbw, $revision, 0 ) ) {
1968
			throw new MWException( "Failed to update page row to use new revision." );
1969
		}
1970
1971
		Hooks::run( 'NewRevisionFromEditComplete', [ $this, $revision, false, $user ] );
1972
1973
		// Update recentchanges
1974
		if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
1975
			// Mark as patrolled if the user can do so
1976
			$patrolled = ( $wgUseRCPatrol || $wgUseNPPatrol ) &&
1977
				!count( $this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) );
1978
			// Add RC row to the DB
1979
			RecentChange::notifyNew(
1980
				$now,
0 ignored issues
show
Security Bug introduced by
It seems like $now defined by wfTimestampNow() on line 1924 can also be of type false; however, RecentChange::notifyNew() does only seem to accept string, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
1981
				$this->mTitle,
1982
				$revision->isMinor(),
1983
				$user,
1984
				$summary,
1985
				$meta['bot'],
1986
				'',
1987
				$newsize,
1988
				$revisionId,
1989
				$patrolled,
1990
				$meta['tags']
1991
			);
1992
		}
1993
1994
		$user->incEditCount();
1995
1996
		$dbw->endAtomic( __METHOD__ );
1997
		$this->mTimestamp = $now;
0 ignored issues
show
Documentation Bug introduced by
It seems like $now can also be of type false. However, the property $mTimestamp is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
1998
1999
		// Return the new revision to the caller
2000
		$status->value['revision'] = $revision;
2001
2002
		// Do secondary updates once the main changes have been committed...
2003
		DeferredUpdates::addUpdate(
2004
			new AtomicSectionUpdate(
2005
				$dbw,
2006
				__METHOD__,
2007
				function () use (
2008
					$revision, &$user, $content, $summary, &$flags, $meta, &$status
2009
				) {
2010
					// Update links, etc.
2011
					$this->doEditUpdates( $revision, $user, [ 'created' => true ] );
2012
					// Trigger post-create hook
2013
					$params = [ &$this, &$user, $content, $summary,
2014
						$flags & EDIT_MINOR, null, null, &$flags, $revision ];
2015
					ContentHandler::runLegacyHooks( 'ArticleInsertComplete', $params );
2016
					Hooks::run( 'PageContentInsertComplete', $params );
2017
					// Trigger post-save hook
2018
					$params = array_merge( $params, [ &$status, $meta['baseRevId'] ] );
2019
					ContentHandler::runLegacyHooks( 'ArticleSaveComplete', $params );
2020
					Hooks::run( 'PageContentSaveComplete', $params );
2021
2022
				}
2023
			),
2024
			DeferredUpdates::PRESEND
2025
		);
2026
2027
		return $status;
2028
	}
2029
2030
	/**
2031
	 * Get parser options suitable for rendering the primary article wikitext
2032
	 *
2033
	 * @see ContentHandler::makeParserOptions
2034
	 *
2035
	 * @param IContextSource|User|string $context One of the following:
2036
	 *        - IContextSource: Use the User and the Language of the provided
2037
	 *          context
2038
	 *        - User: Use the provided User object and $wgLang for the language,
2039
	 *          so use an IContextSource object if possible.
2040
	 *        - 'canonical': Canonical options (anonymous user with default
2041
	 *          preferences and content language).
2042
	 * @return ParserOptions
2043
	 */
2044
	public function makeParserOptions( $context ) {
2045
		$options = $this->getContentHandler()->makeParserOptions( $context );
2046
2047
		if ( $this->getTitle()->isConversionTable() ) {
2048
			// @todo ConversionTable should become a separate content model, so
2049
			// we don't need special cases like this one.
2050
			$options->disableContentConversion();
2051
		}
2052
2053
		return $options;
2054
	}
2055
2056
	/**
2057
	 * Prepare text which is about to be saved.
2058
	 * Returns a stdClass with source, pst and output members
2059
	 *
2060
	 * @param string $text
2061
	 * @param int|null $revid
2062
	 * @param User|null $user
2063
	 * @deprecated since 1.21: use prepareContentForEdit instead.
2064
	 * @return object
2065
	 */
2066
	public function prepareTextForEdit( $text, $revid = null, User $user = null ) {
2067
		ContentHandler::deprecated( __METHOD__, '1.21' );
2068
		$content = ContentHandler::makeContent( $text, $this->getTitle() );
2069
		return $this->prepareContentForEdit( $content, $revid, $user );
2070
	}
2071
2072
	/**
2073
	 * Prepare content which is about to be saved.
2074
	 * Returns a stdClass with source, pst and output members
2075
	 *
2076
	 * @param Content $content
2077
	 * @param Revision|int|null $revision Revision object. For backwards compatibility, a
2078
	 *        revision ID is also accepted, but this is deprecated.
2079
	 * @param User|null $user
2080
	 * @param string|null $serialFormat
2081
	 * @param bool $useCache Check shared prepared edit cache
2082
	 *
2083
	 * @return object
2084
	 *
2085
	 * @since 1.21
2086
	 */
2087
	public function prepareContentForEdit(
2088
		Content $content, $revision = null, User $user = null,
2089
		$serialFormat = null, $useCache = true
2090
	) {
2091
		global $wgContLang, $wgUser, $wgAjaxEditStash;
2092
2093
		if ( is_object( $revision ) ) {
2094
			$revid = $revision->getId();
2095
		} else {
2096
			$revid = $revision;
2097
			// This code path is deprecated, and nothing is known to
2098
			// use it, so performance here shouldn't be a worry.
2099
			if ( $revid !== null ) {
2100
				$revision = Revision::newFromId( $revid, Revision::READ_LATEST );
2101
			} else {
2102
				$revision = null;
2103
			}
2104
		}
2105
2106
		$user = is_null( $user ) ? $wgUser : $user;
2107
		// XXX: check $user->getId() here???
2108
2109
		// Use a sane default for $serialFormat, see bug 57026
2110
		if ( $serialFormat === null ) {
2111
			$serialFormat = $content->getContentHandler()->getDefaultFormat();
2112
		}
2113
2114
		if ( $this->mPreparedEdit
2115
			&& isset( $this->mPreparedEdit->newContent )
2116
			&& $this->mPreparedEdit->newContent->equals( $content )
2117
			&& $this->mPreparedEdit->revid == $revid
2118
			&& $this->mPreparedEdit->format == $serialFormat
2119
			// XXX: also check $user here?
2120
		) {
2121
			// Already prepared
2122
			return $this->mPreparedEdit;
2123
		}
2124
2125
		// The edit may have already been prepared via api.php?action=stashedit
2126
		$cachedEdit = $useCache && $wgAjaxEditStash
2127
			? ApiStashEdit::checkCache( $this->getTitle(), $content, $user )
2128
			: false;
2129
2130
		$popts = ParserOptions::newFromUserAndLang( $user, $wgContLang );
2131
		Hooks::run( 'ArticlePrepareTextForEdit', [ $this, $popts ] );
2132
2133
		$edit = (object)[];
2134
		if ( $cachedEdit ) {
2135
			$edit->timestamp = $cachedEdit->timestamp;
2136
		} else {
2137
			$edit->timestamp = wfTimestampNow();
2138
		}
2139
		// @note: $cachedEdit is safely not used if the rev ID was referenced in the text
2140
		$edit->revid = $revid;
2141
2142
		if ( $cachedEdit ) {
2143
			$edit->pstContent = $cachedEdit->pstContent;
2144
		} else {
2145
			$edit->pstContent = $content
2146
				? $content->preSaveTransform( $this->mTitle, $user, $popts )
2147
				: null;
2148
		}
2149
2150
		$edit->format = $serialFormat;
2151
		$edit->popts = $this->makeParserOptions( 'canonical' );
2152
		if ( $cachedEdit ) {
2153
			$edit->output = $cachedEdit->output;
2154
		} else {
2155
			if ( $revision ) {
2156
				// We get here if vary-revision is set. This means that this page references
2157
				// itself (such as via self-transclusion). In this case, we need to make sure
2158
				// that any such self-references refer to the newly-saved revision, and not
2159
				// to the previous one, which could otherwise happen due to replica DB lag.
2160
				$oldCallback = $edit->popts->getCurrentRevisionCallback();
2161
				$edit->popts->setCurrentRevisionCallback(
2162
					function ( Title $title, $parser = false ) use ( $revision, &$oldCallback ) {
2163
						if ( $title->equals( $revision->getTitle() ) ) {
2164
							return $revision;
2165
						} else {
2166
							return call_user_func( $oldCallback, $title, $parser );
2167
						}
2168
					}
2169
				);
2170
			} else {
2171
				// Try to avoid a second parse if {{REVISIONID}} is used
2172
				$edit->popts->setSpeculativeRevIdCallback( function () {
2173
					return 1 + (int)wfGetDB( DB_MASTER )->selectField(
2174
						'revision',
2175
						'MAX(rev_id)',
2176
						[],
2177
						__METHOD__
2178
					);
2179
				} );
2180
			}
2181
			$edit->output = $edit->pstContent
2182
				? $edit->pstContent->getParserOutput( $this->mTitle, $revid, $edit->popts )
2183
				: null;
2184
		}
2185
2186
		$edit->newContent = $content;
2187
		$edit->oldContent = $this->getContent( Revision::RAW );
2188
2189
		// NOTE: B/C for hooks! don't use these fields!
2190
		$edit->newText = $edit->newContent
2191
			? ContentHandler::getContentText( $edit->newContent )
2192
			: '';
2193
		$edit->oldText = $edit->oldContent
2194
			? ContentHandler::getContentText( $edit->oldContent )
2195
			: '';
2196
		$edit->pst = $edit->pstContent ? $edit->pstContent->serialize( $serialFormat ) : '';
2197
2198
		if ( $edit->output ) {
2199
			$edit->output->setCacheTime( wfTimestampNow() );
2200
		}
2201
2202
		// Process cache the result
2203
		$this->mPreparedEdit = $edit;
2204
2205
		return $edit;
2206
	}
2207
2208
	/**
2209
	 * Do standard deferred updates after page edit.
2210
	 * Update links tables, site stats, search index and message cache.
2211
	 * Purges pages that include this page if the text was changed here.
2212
	 * Every 100th edit, prune the recent changes table.
2213
	 *
2214
	 * @param Revision $revision
2215
	 * @param User $user User object that did the revision
2216
	 * @param array $options Array of options, following indexes are used:
2217
	 * - changed: boolean, whether the revision changed the content (default true)
2218
	 * - created: boolean, whether the revision created the page (default false)
2219
	 * - moved: boolean, whether the page was moved (default false)
2220
	 * - restored: boolean, whether the page was undeleted (default false)
2221
	 * - oldrevision: Revision object for the pre-update revision (default null)
2222
	 * - oldcountable: boolean, null, or string 'no-change' (default null):
2223
	 *   - boolean: whether the page was counted as an article before that
2224
	 *     revision, only used in changed is true and created is false
2225
	 *   - null: if created is false, don't update the article count; if created
2226
	 *     is true, do update the article count
2227
	 *   - 'no-change': don't update the article count, ever
2228
	 */
2229
	public function doEditUpdates( Revision $revision, User $user, array $options = [] ) {
2230
		global $wgRCWatchCategoryMembership, $wgContLang;
2231
2232
		$options += [
2233
			'changed' => true,
2234
			'created' => false,
2235
			'moved' => false,
2236
			'restored' => false,
2237
			'oldrevision' => null,
2238
			'oldcountable' => null
2239
		];
2240
		$content = $revision->getContent();
2241
2242
		$logger = LoggerFactory::getInstance( 'SaveParse' );
2243
2244
		// See if the parser output before $revision was inserted is still valid
2245
		$editInfo = false;
2246
		if ( !$this->mPreparedEdit ) {
2247
			$logger->debug( __METHOD__ . ": No prepared edit...\n" );
2248
		} elseif ( $this->mPreparedEdit->output->getFlag( 'vary-revision' ) ) {
2249
			$logger->info( __METHOD__ . ": Prepared edit has vary-revision...\n" );
2250
		} elseif ( $this->mPreparedEdit->output->getFlag( 'vary-revision-id' )
2251
			&& $this->mPreparedEdit->output->getSpeculativeRevIdUsed() !== $revision->getId()
2252
		) {
2253
			$logger->info( __METHOD__ . ": Prepared edit has vary-revision-id with wrong ID...\n" );
2254
		} elseif ( $this->mPreparedEdit->output->getFlag( 'vary-user' ) && !$options['changed'] ) {
2255
			$logger->info( __METHOD__ . ": Prepared edit has vary-user and is null...\n" );
2256
		} else {
2257
			wfDebug( __METHOD__ . ": Using prepared edit...\n" );
2258
			$editInfo = $this->mPreparedEdit;
2259
		}
2260
2261
		if ( !$editInfo ) {
2262
			// Parse the text again if needed. Be careful not to do pre-save transform twice:
2263
			// $text is usually already pre-save transformed once. Avoid using the edit stash
2264
			// as any prepared content from there or in doEditContent() was already rejected.
2265
			$editInfo = $this->prepareContentForEdit( $content, $revision, $user, null, false );
0 ignored issues
show
Bug introduced by
It seems like $content defined by $revision->getContent() on line 2240 can be null; however, WikiPage::prepareContentForEdit() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
2266
		}
2267
2268
		// Save it to the parser cache.
2269
		// Make sure the cache time matches page_touched to avoid double parsing.
2270
		ParserCache::singleton()->save(
2271
			$editInfo->output, $this, $editInfo->popts,
2272
			$revision->getTimestamp(), $editInfo->revid
0 ignored issues
show
Security Bug introduced by
It seems like $revision->getTimestamp() targeting Revision::getTimestamp() can also be of type false; however, ParserCache::save() does only seem to accept string|null, did you maybe forget to handle an error condition?
Loading history...
2273
		);
2274
2275
		// Update the links tables and other secondary data
2276
		if ( $content ) {
2277
			$recursive = $options['changed']; // bug 50785
2278
			$updates = $content->getSecondaryDataUpdates(
2279
				$this->getTitle(), null, $recursive, $editInfo->output
2280
			);
2281
			foreach ( $updates as $update ) {
2282
				if ( $update instanceof LinksUpdate ) {
2283
					$update->setRevision( $revision );
2284
					$update->setTriggeringUser( $user );
2285
				}
2286
				DeferredUpdates::addUpdate( $update );
2287
			}
2288
			if ( $wgRCWatchCategoryMembership
2289
				&& $this->getContentHandler()->supportsCategories() === true
2290
				&& ( $options['changed'] || $options['created'] )
2291
				&& !$options['restored']
2292
			) {
2293
				// Note: jobs are pushed after deferred updates, so the job should be able to see
2294
				// the recent change entry (also done via deferred updates) and carry over any
2295
				// bot/deletion/IP flags, ect.
2296
				JobQueueGroup::singleton()->lazyPush( new CategoryMembershipChangeJob(
2297
					$this->getTitle(),
2298
					[
2299
						'pageId' => $this->getId(),
2300
						'revTimestamp' => $revision->getTimestamp()
2301
					]
2302
				) );
2303
			}
2304
		}
2305
2306
		Hooks::run( 'ArticleEditUpdates', [ &$this, &$editInfo, $options['changed'] ] );
2307
2308
		if ( Hooks::run( 'ArticleEditUpdatesDeleteFromRecentchanges', [ &$this ] ) ) {
2309
			// Flush old entries from the `recentchanges` table
2310
			if ( mt_rand( 0, 9 ) == 0 ) {
2311
				JobQueueGroup::singleton()->lazyPush( RecentChangesUpdateJob::newPurgeJob() );
2312
			}
2313
		}
2314
2315
		if ( !$this->exists() ) {
2316
			return;
2317
		}
2318
2319
		$id = $this->getId();
2320
		$title = $this->mTitle->getPrefixedDBkey();
2321
		$shortTitle = $this->mTitle->getDBkey();
2322
2323
		if ( $options['oldcountable'] === 'no-change' ||
2324
			( !$options['changed'] && !$options['moved'] )
2325
		) {
2326
			$good = 0;
2327
		} elseif ( $options['created'] ) {
2328
			$good = (int)$this->isCountable( $editInfo );
2329
		} elseif ( $options['oldcountable'] !== null ) {
2330
			$good = (int)$this->isCountable( $editInfo ) - (int)$options['oldcountable'];
2331
		} else {
2332
			$good = 0;
2333
		}
2334
		$edits = $options['changed'] ? 1 : 0;
2335
		$total = $options['created'] ? 1 : 0;
2336
2337
		DeferredUpdates::addUpdate( new SiteStatsUpdate( 0, $edits, $good, $total ) );
2338
		DeferredUpdates::addUpdate( new SearchUpdate( $id, $title, $content ) );
0 ignored issues
show
Bug introduced by
It seems like $content defined by $revision->getContent() on line 2240 can be null; however, SearchUpdate::__construct() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
2339
2340
		// If this is another user's talk page, update newtalk.
2341
		// Don't do this if $options['changed'] = false (null-edits) nor if
2342
		// it's a minor edit and the user doesn't want notifications for those.
2343
		if ( $options['changed']
2344
			&& $this->mTitle->getNamespace() == NS_USER_TALK
2345
			&& $shortTitle != $user->getTitleKey()
2346
			&& !( $revision->isMinor() && $user->isAllowed( 'nominornewtalk' ) )
2347
		) {
2348
			$recipient = User::newFromName( $shortTitle, false );
2349
			if ( !$recipient ) {
2350
				wfDebug( __METHOD__ . ": invalid username\n" );
2351
			} else {
2352
				// Allow extensions to prevent user notification
2353
				// when a new message is added to their talk page
2354
				if ( Hooks::run( 'ArticleEditUpdateNewTalk', [ &$this, $recipient ] ) ) {
2355
					if ( User::isIP( $shortTitle ) ) {
2356
						// An anonymous user
2357
						$recipient->setNewtalk( true, $revision );
2358
					} elseif ( $recipient->isLoggedIn() ) {
2359
						$recipient->setNewtalk( true, $revision );
2360
					} else {
2361
						wfDebug( __METHOD__ . ": don't need to notify a nonexistent user\n" );
2362
					}
2363
				}
2364
			}
2365
		}
2366
2367
		if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) {
2368
			// XXX: could skip pseudo-messages like js/css here, based on content model.
2369
			$msgtext = $content ? $content->getWikitextForTransclusion() : null;
2370
			if ( $msgtext === false || $msgtext === null ) {
2371
				$msgtext = '';
2372
			}
2373
2374
			MessageCache::singleton()->replace( $shortTitle, $msgtext );
2375
2376
			if ( $wgContLang->hasVariants() ) {
2377
				$wgContLang->updateConversionTable( $this->mTitle );
2378
			}
2379
		}
2380
2381
		if ( $options['created'] ) {
2382
			self::onArticleCreate( $this->mTitle );
2383
		} elseif ( $options['changed'] ) { // bug 50785
2384
			self::onArticleEdit( $this->mTitle, $revision );
2385
		}
2386
	}
2387
2388
	/**
2389
	 * Edit an article without doing all that other stuff
2390
	 * The article must already exist; link tables etc
2391
	 * are not updated, caches are not flushed.
2392
	 *
2393
	 * @param Content $content Content submitted
2394
	 * @param User $user The relevant user
2395
	 * @param string $comment Comment submitted
2396
	 * @param bool $minor Whereas it's a minor modification
2397
	 * @param string $serialFormat Format for storing the content in the database
2398
	 */
2399
	public function doQuickEditContent(
2400
		Content $content, User $user, $comment = '', $minor = false, $serialFormat = null
2401
	) {
2402
2403
		$serialized = $content->serialize( $serialFormat );
2404
2405
		$dbw = wfGetDB( DB_MASTER );
2406
		$revision = new Revision( [
2407
			'title'      => $this->getTitle(), // for determining the default content model
2408
			'page'       => $this->getId(),
2409
			'user_text'  => $user->getName(),
2410
			'user'       => $user->getId(),
2411
			'text'       => $serialized,
2412
			'length'     => $content->getSize(),
2413
			'comment'    => $comment,
2414
			'minor_edit' => $minor ? 1 : 0,
2415
		] ); // XXX: set the content object?
2416
		$revision->insertOn( $dbw );
2417
		$this->updateRevisionOn( $dbw, $revision );
2418
2419
		Hooks::run( 'NewRevisionFromEditComplete', [ $this, $revision, false, $user ] );
2420
2421
	}
2422
2423
	/**
2424
	 * Update the article's restriction field, and leave a log entry.
2425
	 * This works for protection both existing and non-existing pages.
2426
	 *
2427
	 * @param array $limit Set of restriction keys
2428
	 * @param array $expiry Per restriction type expiration
2429
	 * @param int &$cascade Set to false if cascading protection isn't allowed.
2430
	 * @param string $reason
2431
	 * @param User $user The user updating the restrictions
2432
	 * @param string|string[] $tags Change tags to add to the pages and protection log entries
2433
	 *   ($user should be able to add the specified tags before this is called)
2434
	 * @return Status Status object; if action is taken, $status->value is the log_id of the
2435
	 *   protection log entry.
2436
	 */
2437
	public function doUpdateRestrictions( array $limit, array $expiry,
2438
		&$cascade, $reason, User $user, $tags = null
2439
	) {
2440
		global $wgCascadingRestrictionLevels, $wgContLang;
2441
2442
		if ( wfReadOnly() ) {
2443
			return Status::newFatal( 'readonlytext', wfReadOnlyReason() );
2444
		}
2445
2446
		$this->loadPageData( 'fromdbmaster' );
2447
		$restrictionTypes = $this->mTitle->getRestrictionTypes();
2448
		$id = $this->getId();
2449
2450
		if ( !$cascade ) {
2451
			$cascade = false;
2452
		}
2453
2454
		// Take this opportunity to purge out expired restrictions
2455
		Title::purgeExpiredRestrictions();
2456
2457
		// @todo FIXME: Same limitations as described in ProtectionForm.php (line 37);
2458
		// we expect a single selection, but the schema allows otherwise.
2459
		$isProtected = false;
2460
		$protect = false;
2461
		$changed = false;
2462
2463
		$dbw = wfGetDB( DB_MASTER );
2464
2465
		foreach ( $restrictionTypes as $action ) {
2466
			if ( !isset( $expiry[$action] ) || $expiry[$action] === $dbw->getInfinity() ) {
2467
				$expiry[$action] = 'infinity';
2468
			}
2469
			if ( !isset( $limit[$action] ) ) {
2470
				$limit[$action] = '';
2471
			} elseif ( $limit[$action] != '' ) {
2472
				$protect = true;
2473
			}
2474
2475
			// Get current restrictions on $action
2476
			$current = implode( '', $this->mTitle->getRestrictions( $action ) );
2477
			if ( $current != '' ) {
2478
				$isProtected = true;
2479
			}
2480
2481
			if ( $limit[$action] != $current ) {
2482
				$changed = true;
2483
			} elseif ( $limit[$action] != '' ) {
2484
				// Only check expiry change if the action is actually being
2485
				// protected, since expiry does nothing on an not-protected
2486
				// action.
2487
				if ( $this->mTitle->getRestrictionExpiry( $action ) != $expiry[$action] ) {
2488
					$changed = true;
2489
				}
2490
			}
2491
		}
2492
2493
		if ( !$changed && $protect && $this->mTitle->areRestrictionsCascading() != $cascade ) {
2494
			$changed = true;
2495
		}
2496
2497
		// If nothing has changed, do nothing
2498
		if ( !$changed ) {
2499
			return Status::newGood();
2500
		}
2501
2502
		if ( !$protect ) { // No protection at all means unprotection
2503
			$revCommentMsg = 'unprotectedarticle';
2504
			$logAction = 'unprotect';
2505
		} elseif ( $isProtected ) {
2506
			$revCommentMsg = 'modifiedarticleprotection';
2507
			$logAction = 'modify';
2508
		} else {
2509
			$revCommentMsg = 'protectedarticle';
2510
			$logAction = 'protect';
2511
		}
2512
2513
		// Truncate for whole multibyte characters
2514
		$reason = $wgContLang->truncate( $reason, 255 );
2515
2516
		$logRelationsValues = [];
2517
		$logRelationsField = null;
2518
		$logParamsDetails = [];
2519
2520
		// Null revision (used for change tag insertion)
2521
		$nullRevision = null;
2522
2523
		if ( $id ) { // Protection of existing page
2524
			if ( !Hooks::run( 'ArticleProtect', [ &$this, &$user, $limit, $reason ] ) ) {
2525
				return Status::newGood();
2526
			}
2527
2528
			// Only certain restrictions can cascade...
2529
			$editrestriction = isset( $limit['edit'] )
2530
				? [ $limit['edit'] ]
2531
				: $this->mTitle->getRestrictions( 'edit' );
2532
			foreach ( array_keys( $editrestriction, 'sysop' ) as $key ) {
2533
				$editrestriction[$key] = 'editprotected'; // backwards compatibility
2534
			}
2535
			foreach ( array_keys( $editrestriction, 'autoconfirmed' ) as $key ) {
2536
				$editrestriction[$key] = 'editsemiprotected'; // backwards compatibility
2537
			}
2538
2539
			$cascadingRestrictionLevels = $wgCascadingRestrictionLevels;
2540
			foreach ( array_keys( $cascadingRestrictionLevels, 'sysop' ) as $key ) {
2541
				$cascadingRestrictionLevels[$key] = 'editprotected'; // backwards compatibility
2542
			}
2543
			foreach ( array_keys( $cascadingRestrictionLevels, 'autoconfirmed' ) as $key ) {
2544
				$cascadingRestrictionLevels[$key] = 'editsemiprotected'; // backwards compatibility
2545
			}
2546
2547
			// The schema allows multiple restrictions
2548
			if ( !array_intersect( $editrestriction, $cascadingRestrictionLevels ) ) {
2549
				$cascade = false;
2550
			}
2551
2552
			// insert null revision to identify the page protection change as edit summary
2553
			$latest = $this->getLatest();
2554
			$nullRevision = $this->insertProtectNullRevision(
2555
				$revCommentMsg,
2556
				$limit,
2557
				$expiry,
2558
				$cascade,
2559
				$reason,
2560
				$user
2561
			);
2562
2563
			if ( $nullRevision === null ) {
2564
				return Status::newFatal( 'no-null-revision', $this->mTitle->getPrefixedText() );
2565
			}
2566
2567
			$logRelationsField = 'pr_id';
2568
2569
			// Update restrictions table
2570
			foreach ( $limit as $action => $restrictions ) {
2571
				$dbw->delete(
2572
					'page_restrictions',
2573
					[
2574
						'pr_page' => $id,
2575
						'pr_type' => $action
2576
					],
2577
					__METHOD__
2578
				);
2579
				if ( $restrictions != '' ) {
2580
					$cascadeValue = ( $cascade && $action == 'edit' ) ? 1 : 0;
2581
					$dbw->insert(
2582
						'page_restrictions',
2583
						[
2584
							'pr_id' => $dbw->nextSequenceValue( 'page_restrictions_pr_id_seq' ),
2585
							'pr_page' => $id,
2586
							'pr_type' => $action,
2587
							'pr_level' => $restrictions,
2588
							'pr_cascade' => $cascadeValue,
2589
							'pr_expiry' => $dbw->encodeExpiry( $expiry[$action] )
2590
						],
2591
						__METHOD__
2592
					);
2593
					$logRelationsValues[] = $dbw->insertId();
2594
					$logParamsDetails[] = [
2595
						'type' => $action,
2596
						'level' => $restrictions,
2597
						'expiry' => $expiry[$action],
2598
						'cascade' => (bool)$cascadeValue,
2599
					];
2600
				}
2601
			}
2602
2603
			// Clear out legacy restriction fields
2604
			$dbw->update(
2605
				'page',
2606
				[ 'page_restrictions' => '' ],
2607
				[ 'page_id' => $id ],
2608
				__METHOD__
2609
			);
2610
2611
			Hooks::run( 'NewRevisionFromEditComplete',
2612
				[ $this, $nullRevision, $latest, $user ] );
2613
			Hooks::run( 'ArticleProtectComplete', [ &$this, &$user, $limit, $reason ] );
2614
		} else { // Protection of non-existing page (also known as "title protection")
2615
			// Cascade protection is meaningless in this case
2616
			$cascade = false;
2617
2618
			if ( $limit['create'] != '' ) {
2619
				$dbw->replace( 'protected_titles',
2620
					[ [ 'pt_namespace', 'pt_title' ] ],
2621
					[
2622
						'pt_namespace' => $this->mTitle->getNamespace(),
2623
						'pt_title' => $this->mTitle->getDBkey(),
2624
						'pt_create_perm' => $limit['create'],
2625
						'pt_timestamp' => $dbw->timestamp(),
2626
						'pt_expiry' => $dbw->encodeExpiry( $expiry['create'] ),
2627
						'pt_user' => $user->getId(),
2628
						'pt_reason' => $reason,
2629
					], __METHOD__
2630
				);
2631
				$logParamsDetails[] = [
2632
					'type' => 'create',
2633
					'level' => $limit['create'],
2634
					'expiry' => $expiry['create'],
2635
				];
2636
			} else {
2637
				$dbw->delete( 'protected_titles',
2638
					[
2639
						'pt_namespace' => $this->mTitle->getNamespace(),
2640
						'pt_title' => $this->mTitle->getDBkey()
2641
					], __METHOD__
2642
				);
2643
			}
2644
		}
2645
2646
		$this->mTitle->flushRestrictions();
2647
		InfoAction::invalidateCache( $this->mTitle );
2648
2649
		if ( $logAction == 'unprotect' ) {
2650
			$params = [];
2651
		} else {
2652
			$protectDescriptionLog = $this->protectDescriptionLog( $limit, $expiry );
2653
			$params = [
2654
				'4::description' => $protectDescriptionLog, // parameter for IRC
2655
				'5:bool:cascade' => $cascade,
2656
				'details' => $logParamsDetails, // parameter for localize and api
2657
			];
2658
		}
2659
2660
		// Update the protection log
2661
		$logEntry = new ManualLogEntry( 'protect', $logAction );
2662
		$logEntry->setTarget( $this->mTitle );
2663
		$logEntry->setComment( $reason );
2664
		$logEntry->setPerformer( $user );
2665
		$logEntry->setParameters( $params );
2666
		if ( !is_null( $nullRevision ) ) {
2667
			$logEntry->setAssociatedRevId( $nullRevision->getId() );
2668
		}
2669
		$logEntry->setTags( $tags );
0 ignored issues
show
Bug introduced by
It seems like $tags defined by parameter $tags on line 2438 can also be of type null; however, ManualLogEntry::setTags() does only seem to accept string|array<integer,string>, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
2670
		if ( $logRelationsField !== null && count( $logRelationsValues ) ) {
2671
			$logEntry->setRelations( [ $logRelationsField => $logRelationsValues ] );
2672
		}
2673
		$logId = $logEntry->insert();
2674
		$logEntry->publish( $logId );
2675
2676
		return Status::newGood( $logId );
2677
	}
2678
2679
	/**
2680
	 * Insert a new null revision for this page.
2681
	 *
2682
	 * @param string $revCommentMsg Comment message key for the revision
2683
	 * @param array $limit Set of restriction keys
2684
	 * @param array $expiry Per restriction type expiration
2685
	 * @param int $cascade Set to false if cascading protection isn't allowed.
2686
	 * @param string $reason
2687
	 * @param User|null $user
2688
	 * @return Revision|null Null on error
2689
	 */
2690
	public function insertProtectNullRevision( $revCommentMsg, array $limit,
2691
		array $expiry, $cascade, $reason, $user = null
2692
	) {
2693
		global $wgContLang;
2694
		$dbw = wfGetDB( DB_MASTER );
2695
2696
		// Prepare a null revision to be added to the history
2697
		$editComment = $wgContLang->ucfirst(
2698
			wfMessage(
2699
				$revCommentMsg,
2700
				$this->mTitle->getPrefixedText()
2701
			)->inContentLanguage()->text()
2702
		);
2703
		if ( $reason ) {
2704
			$editComment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
2705
		}
2706
		$protectDescription = $this->protectDescription( $limit, $expiry );
2707
		if ( $protectDescription ) {
2708
			$editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2709
			$editComment .= wfMessage( 'parentheses' )->params( $protectDescription )
2710
				->inContentLanguage()->text();
2711
		}
2712
		if ( $cascade ) {
2713
			$editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2714
			$editComment .= wfMessage( 'brackets' )->params(
2715
				wfMessage( 'protect-summary-cascade' )->inContentLanguage()->text()
2716
			)->inContentLanguage()->text();
2717
		}
2718
2719
		$nullRev = Revision::newNullRevision( $dbw, $this->getId(), $editComment, true, $user );
2720
		if ( $nullRev ) {
2721
			$nullRev->insertOn( $dbw );
2722
2723
			// Update page record and touch page
2724
			$oldLatest = $nullRev->getParentId();
2725
			$this->updateRevisionOn( $dbw, $nullRev, $oldLatest );
2726
		}
2727
2728
		return $nullRev;
2729
	}
2730
2731
	/**
2732
	 * @param string $expiry 14-char timestamp or "infinity", or false if the input was invalid
2733
	 * @return string
2734
	 */
2735
	protected function formatExpiry( $expiry ) {
2736
		global $wgContLang;
2737
2738
		if ( $expiry != 'infinity' ) {
2739
			return wfMessage(
2740
				'protect-expiring',
2741
				$wgContLang->timeanddate( $expiry, false, false ),
2742
				$wgContLang->date( $expiry, false, false ),
2743
				$wgContLang->time( $expiry, false, false )
2744
			)->inContentLanguage()->text();
2745
		} else {
2746
			return wfMessage( 'protect-expiry-indefinite' )
2747
				->inContentLanguage()->text();
2748
		}
2749
	}
2750
2751
	/**
2752
	 * Builds the description to serve as comment for the edit.
2753
	 *
2754
	 * @param array $limit Set of restriction keys
2755
	 * @param array $expiry Per restriction type expiration
2756
	 * @return string
2757
	 */
2758
	public function protectDescription( array $limit, array $expiry ) {
2759
		$protectDescription = '';
2760
2761
		foreach ( array_filter( $limit ) as $action => $restrictions ) {
2762
			# $action is one of $wgRestrictionTypes = [ 'create', 'edit', 'move', 'upload' ].
2763
			# All possible message keys are listed here for easier grepping:
2764
			# * restriction-create
2765
			# * restriction-edit
2766
			# * restriction-move
2767
			# * restriction-upload
2768
			$actionText = wfMessage( 'restriction-' . $action )->inContentLanguage()->text();
2769
			# $restrictions is one of $wgRestrictionLevels = [ '', 'autoconfirmed', 'sysop' ],
2770
			# with '' filtered out. All possible message keys are listed below:
2771
			# * protect-level-autoconfirmed
2772
			# * protect-level-sysop
2773
			$restrictionsText = wfMessage( 'protect-level-' . $restrictions )
2774
				->inContentLanguage()->text();
2775
2776
			$expiryText = $this->formatExpiry( $expiry[$action] );
2777
2778
			if ( $protectDescription !== '' ) {
2779
				$protectDescription .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2780
			}
2781
			$protectDescription .= wfMessage( 'protect-summary-desc' )
2782
				->params( $actionText, $restrictionsText, $expiryText )
2783
				->inContentLanguage()->text();
2784
		}
2785
2786
		return $protectDescription;
2787
	}
2788
2789
	/**
2790
	 * Builds the description to serve as comment for the log entry.
2791
	 *
2792
	 * Some bots may parse IRC lines, which are generated from log entries which contain plain
2793
	 * protect description text. Keep them in old format to avoid breaking compatibility.
2794
	 * TODO: Fix protection log to store structured description and format it on-the-fly.
2795
	 *
2796
	 * @param array $limit Set of restriction keys
2797
	 * @param array $expiry Per restriction type expiration
2798
	 * @return string
2799
	 */
2800
	public function protectDescriptionLog( array $limit, array $expiry ) {
2801
		global $wgContLang;
2802
2803
		$protectDescriptionLog = '';
2804
2805
		foreach ( array_filter( $limit ) as $action => $restrictions ) {
2806
			$expiryText = $this->formatExpiry( $expiry[$action] );
2807
			$protectDescriptionLog .= $wgContLang->getDirMark() .
2808
				"[$action=$restrictions] ($expiryText)";
2809
		}
2810
2811
		return trim( $protectDescriptionLog );
2812
	}
2813
2814
	/**
2815
	 * Take an array of page restrictions and flatten it to a string
2816
	 * suitable for insertion into the page_restrictions field.
2817
	 *
2818
	 * @param string[] $limit
2819
	 *
2820
	 * @throws MWException
2821
	 * @return string
2822
	 */
2823
	protected static function flattenRestrictions( $limit ) {
2824
		if ( !is_array( $limit ) ) {
2825
			throw new MWException( __METHOD__ . ' given non-array restriction set' );
2826
		}
2827
2828
		$bits = [];
2829
		ksort( $limit );
2830
2831
		foreach ( array_filter( $limit ) as $action => $restrictions ) {
2832
			$bits[] = "$action=$restrictions";
2833
		}
2834
2835
		return implode( ':', $bits );
2836
	}
2837
2838
	/**
2839
	 * Same as doDeleteArticleReal(), but returns a simple boolean. This is kept around for
2840
	 * backwards compatibility, if you care about error reporting you should use
2841
	 * doDeleteArticleReal() instead.
2842
	 *
2843
	 * Deletes the article with database consistency, writes logs, purges caches
2844
	 *
2845
	 * @param string $reason Delete reason for deletion log
2846
	 * @param bool $suppress Suppress all revisions and log the deletion in
2847
	 *        the suppression log instead of the deletion log
2848
	 * @param int $u1 Unused
2849
	 * @param bool $u2 Unused
2850
	 * @param array|string &$error Array of errors to append to
2851
	 * @param User $user The deleting user
2852
	 * @return bool True if successful
2853
	 */
2854
	public function doDeleteArticle(
2855
		$reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null
2856
	) {
2857
		$status = $this->doDeleteArticleReal( $reason, $suppress, $u1, $u2, $error, $user );
2858
		return $status->isGood();
2859
	}
2860
2861
	/**
2862
	 * Back-end article deletion
2863
	 * Deletes the article with database consistency, writes logs, purges caches
2864
	 *
2865
	 * @since 1.19
2866
	 *
2867
	 * @param string $reason Delete reason for deletion log
2868
	 * @param bool $suppress Suppress all revisions and log the deletion in
2869
	 *   the suppression log instead of the deletion log
2870
	 * @param int $u1 Unused
2871
	 * @param bool $u2 Unused
2872
	 * @param array|string &$error Array of errors to append to
2873
	 * @param User $user The deleting user
2874
	 * @return Status Status object; if successful, $status->value is the log_id of the
2875
	 *   deletion log entry. If the page couldn't be deleted because it wasn't
2876
	 *   found, $status is a non-fatal 'cannotdelete' error
2877
	 */
2878
	public function doDeleteArticleReal(
2879
		$reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null
2880
	) {
2881
		global $wgUser, $wgContentHandlerUseDB;
2882
2883
		wfDebug( __METHOD__ . "\n" );
2884
2885
		$status = Status::newGood();
2886
2887
		if ( $this->mTitle->getDBkey() === '' ) {
2888
			$status->error( 'cannotdelete',
2889
				wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
2890
			return $status;
2891
		}
2892
2893
		$user = is_null( $user ) ? $wgUser : $user;
2894
		if ( !Hooks::run( 'ArticleDelete',
2895
			[ &$this, &$user, &$reason, &$error, &$status, $suppress ]
2896
		) ) {
2897
			if ( $status->isOK() ) {
2898
				// Hook aborted but didn't set a fatal status
2899
				$status->fatal( 'delete-hook-aborted' );
2900
			}
2901
			return $status;
2902
		}
2903
2904
		$dbw = wfGetDB( DB_MASTER );
2905
		$dbw->startAtomic( __METHOD__ );
2906
2907
		$this->loadPageData( self::READ_LATEST );
2908
		$id = $this->getId();
2909
		// T98706: lock the page from various other updates but avoid using
2910
		// WikiPage::READ_LOCKING as that will carry over the FOR UPDATE to
2911
		// the revisions queries (which also JOIN on user). Only lock the page
2912
		// row and CAS check on page_latest to see if the trx snapshot matches.
2913
		$lockedLatest = $this->lockAndGetLatest();
2914
		if ( $id == 0 || $this->getLatest() != $lockedLatest ) {
2915
			$dbw->endAtomic( __METHOD__ );
2916
			// Page not there or trx snapshot is stale
2917
			$status->error( 'cannotdelete',
2918
				wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
2919
			return $status;
2920
		}
2921
2922
		// Given the lock above, we can be confident in the title and page ID values
2923
		$namespace = $this->getTitle()->getNamespace();
2924
		$dbKey = $this->getTitle()->getDBkey();
2925
2926
		// At this point we are now comitted to returning an OK
2927
		// status unless some DB query error or other exception comes up.
2928
		// This way callers don't have to call rollback() if $status is bad
2929
		// unless they actually try to catch exceptions (which is rare).
2930
2931
		// we need to remember the old content so we can use it to generate all deletion updates.
2932
		try {
2933
			$content = $this->getContent( Revision::RAW );
2934
		} catch ( Exception $ex ) {
2935
			wfLogWarning( __METHOD__ . ': failed to load content during deletion! '
2936
				. $ex->getMessage() );
2937
2938
			$content = null;
2939
		}
2940
2941
		// Bitfields to further suppress the content
2942
		if ( $suppress ) {
2943
			$bitfield = 0;
2944
			// This should be 15...
2945
			$bitfield |= Revision::DELETED_TEXT;
2946
			$bitfield |= Revision::DELETED_COMMENT;
2947
			$bitfield |= Revision::DELETED_USER;
2948
			$bitfield |= Revision::DELETED_RESTRICTED;
2949
			$deletionFields = [ $dbw->addQuotes( $bitfield ) . ' AS deleted' ];
2950
		} else {
2951
			$deletionFields = [ 'rev_deleted AS deleted' ];
2952
		}
2953
2954
		// For now, shunt the revision data into the archive table.
2955
		// Text is *not* removed from the text table; bulk storage
2956
		// is left intact to avoid breaking block-compression or
2957
		// immutable storage schemes.
2958
		// In the future, we may keep revisions and mark them with
2959
		// the rev_deleted field, which is reserved for this purpose.
2960
2961
		// Get all of the page revisions
2962
		$fields = array_diff( Revision::selectFields(), [ 'rev_deleted' ] );
2963
		$res = $dbw->select(
2964
			'revision',
2965
			array_merge( $fields, $deletionFields ),
2966
			[ 'rev_page' => $id ],
2967
			__METHOD__,
2968
			'FOR UPDATE'
2969
		);
2970
		// Build their equivalent archive rows
2971
		$rowsInsert = [];
2972
		foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type object<ResultWrapper>|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
2973
			$rowInsert = [
2974
				'ar_namespace'  => $namespace,
2975
				'ar_title'      => $dbKey,
2976
				'ar_comment'    => $row->rev_comment,
2977
				'ar_user'       => $row->rev_user,
2978
				'ar_user_text'  => $row->rev_user_text,
2979
				'ar_timestamp'  => $row->rev_timestamp,
2980
				'ar_minor_edit' => $row->rev_minor_edit,
2981
				'ar_rev_id'     => $row->rev_id,
2982
				'ar_parent_id'  => $row->rev_parent_id,
2983
				'ar_text_id'    => $row->rev_text_id,
2984
				'ar_text'       => '',
2985
				'ar_flags'      => '',
2986
				'ar_len'        => $row->rev_len,
2987
				'ar_page_id'    => $id,
2988
				'ar_deleted'    => $row->deleted,
2989
				'ar_sha1'       => $row->rev_sha1,
2990
			];
2991
			if ( $wgContentHandlerUseDB ) {
2992
				$rowInsert['ar_content_model'] = $row->rev_content_model;
2993
				$rowInsert['ar_content_format'] = $row->rev_content_format;
2994
			}
2995
			$rowsInsert[] = $rowInsert;
2996
		}
2997
		// Copy them into the archive table
2998
		$dbw->insert( 'archive', $rowsInsert, __METHOD__ );
2999
		// Save this so we can pass it to the ArticleDeleteComplete hook.
3000
		$archivedRevisionCount = $dbw->affectedRows();
3001
3002
		// Clone the title and wikiPage, so we have the information we need when
3003
		// we log and run the ArticleDeleteComplete hook.
3004
		$logTitle = clone $this->mTitle;
3005
		$wikiPageBeforeDelete = clone $this;
3006
3007
		// Now that it's safely backed up, delete it
3008
		$dbw->delete( 'page', [ 'page_id' => $id ], __METHOD__ );
3009
3010
		if ( !$dbw->cascadingDeletes() ) {
3011
			$dbw->delete( 'revision', [ 'rev_page' => $id ], __METHOD__ );
3012
		}
3013
3014
		// Log the deletion, if the page was suppressed, put it in the suppression log instead
3015
		$logtype = $suppress ? 'suppress' : 'delete';
3016
3017
		$logEntry = new ManualLogEntry( $logtype, 'delete' );
3018
		$logEntry->setPerformer( $user );
3019
		$logEntry->setTarget( $logTitle );
3020
		$logEntry->setComment( $reason );
3021
		$logid = $logEntry->insert();
3022
3023
		$dbw->onTransactionPreCommitOrIdle( function () use ( $dbw, $logEntry, $logid ) {
3024
			// Bug 56776: avoid deadlocks (especially from FileDeleteForm)
3025
			$logEntry->publish( $logid );
3026
		} );
3027
3028
		$dbw->endAtomic( __METHOD__ );
3029
3030
		$this->doDeleteUpdates( $id, $content );
3031
3032
		Hooks::run( 'ArticleDeleteComplete', [
3033
			&$wikiPageBeforeDelete,
3034
			&$user,
3035
			$reason,
3036
			$id,
3037
			$content,
3038
			$logEntry,
3039
			$archivedRevisionCount
3040
		] );
3041
		$status->value = $logid;
3042
3043
		// Show log excerpt on 404 pages rather than just a link
3044
		$cache = ObjectCache::getMainStashInstance();
3045
		$key = wfMemcKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
3046
		$cache->set( $key, 1, $cache::TTL_DAY );
3047
3048
		return $status;
3049
	}
3050
3051
	/**
3052
	 * Lock the page row for this title+id and return page_latest (or 0)
3053
	 *
3054
	 * @return integer Returns 0 if no row was found with this title+id
3055
	 * @since 1.27
3056
	 */
3057
	public function lockAndGetLatest() {
3058
		return (int)wfGetDB( DB_MASTER )->selectField(
3059
			'page',
3060
			'page_latest',
3061
			[
3062
				'page_id' => $this->getId(),
3063
				// Typically page_id is enough, but some code might try to do
3064
				// updates assuming the title is the same, so verify that
3065
				'page_namespace' => $this->getTitle()->getNamespace(),
3066
				'page_title' => $this->getTitle()->getDBkey()
3067
			],
3068
			__METHOD__,
3069
			[ 'FOR UPDATE' ]
3070
		);
3071
	}
3072
3073
	/**
3074
	 * Do some database updates after deletion
3075
	 *
3076
	 * @param int $id The page_id value of the page being deleted
3077
	 * @param Content $content Optional page content to be used when determining
3078
	 *   the required updates. This may be needed because $this->getContent()
3079
	 *   may already return null when the page proper was deleted.
3080
	 */
3081
	public function doDeleteUpdates( $id, Content $content = null ) {
3082
		try {
3083
			$countable = $this->isCountable();
3084
		} catch ( Exception $ex ) {
3085
			// fallback for deleting broken pages for which we cannot load the content for
3086
			// some reason. Note that doDeleteArticleReal() already logged this problem.
3087
			$countable = false;
3088
		}
3089
3090
		// Update site status
3091
		DeferredUpdates::addUpdate( new SiteStatsUpdate( 0, 1, - (int)$countable, -1 ) );
3092
3093
		// Delete pagelinks, update secondary indexes, etc
3094
		$updates = $this->getDeletionUpdates( $content );
3095
		foreach ( $updates as $update ) {
3096
			DeferredUpdates::addUpdate( $update );
3097
		}
3098
3099
		// Reparse any pages transcluding this page
3100
		LinksUpdate::queueRecursiveJobsForTable( $this->mTitle, 'templatelinks' );
3101
3102
		// Reparse any pages including this image
3103
		if ( $this->mTitle->getNamespace() == NS_FILE ) {
3104
			LinksUpdate::queueRecursiveJobsForTable( $this->mTitle, 'imagelinks' );
3105
		}
3106
3107
		// Clear caches
3108
		WikiPage::onArticleDelete( $this->mTitle );
3109
3110
		// Reset this object and the Title object
3111
		$this->loadFromRow( false, self::READ_LATEST );
3112
3113
		// Search engine
3114
		DeferredUpdates::addUpdate( new SearchUpdate( $id, $this->mTitle ) );
3115
	}
3116
3117
	/**
3118
	 * Roll back the most recent consecutive set of edits to a page
3119
	 * from the same user; fails if there are no eligible edits to
3120
	 * roll back to, e.g. user is the sole contributor. This function
3121
	 * performs permissions checks on $user, then calls commitRollback()
3122
	 * to do the dirty work
3123
	 *
3124
	 * @todo Separate the business/permission stuff out from backend code
3125
	 * @todo Remove $token parameter. Already verified by RollbackAction and ApiRollback.
3126
	 *
3127
	 * @param string $fromP Name of the user whose edits to rollback.
3128
	 * @param string $summary Custom summary. Set to default summary if empty.
3129
	 * @param string $token Rollback token.
3130
	 * @param bool $bot If true, mark all reverted edits as bot.
3131
	 *
3132
	 * @param array $resultDetails Array contains result-specific array of additional values
3133
	 *    'alreadyrolled' : 'current' (rev)
3134
	 *    success        : 'summary' (str), 'current' (rev), 'target' (rev)
3135
	 *
3136
	 * @param User $user The user performing the rollback
3137
	 * @param array|null $tags Change tags to apply to the rollback
3138
	 * Callers are responsible for permission checks
3139
	 * (with ChangeTags::canAddTagsAccompanyingChange)
3140
	 *
3141
	 * @return array Array of errors, each error formatted as
3142
	 *   array(messagekey, param1, param2, ...).
3143
	 * On success, the array is empty.  This array can also be passed to
3144
	 * OutputPage::showPermissionsErrorPage().
3145
	 */
3146
	public function doRollback(
3147
		$fromP, $summary, $token, $bot, &$resultDetails, User $user, $tags = null
3148
	) {
3149
		$resultDetails = null;
3150
3151
		// Check permissions
3152
		$editErrors = $this->mTitle->getUserPermissionsErrors( 'edit', $user );
3153
		$rollbackErrors = $this->mTitle->getUserPermissionsErrors( 'rollback', $user );
3154
		$errors = array_merge( $editErrors, wfArrayDiff2( $rollbackErrors, $editErrors ) );
3155
3156
		if ( !$user->matchEditToken( $token, 'rollback' ) ) {
3157
			$errors[] = [ 'sessionfailure' ];
3158
		}
3159
3160
		if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) {
3161
			$errors[] = [ 'actionthrottledtext' ];
3162
		}
3163
3164
		// If there were errors, bail out now
3165
		if ( !empty( $errors ) ) {
3166
			return $errors;
3167
		}
3168
3169
		return $this->commitRollback( $fromP, $summary, $bot, $resultDetails, $user, $tags );
3170
	}
3171
3172
	/**
3173
	 * Backend implementation of doRollback(), please refer there for parameter
3174
	 * and return value documentation
3175
	 *
3176
	 * NOTE: This function does NOT check ANY permissions, it just commits the
3177
	 * rollback to the DB. Therefore, you should only call this function direct-
3178
	 * ly if you want to use custom permissions checks. If you don't, use
3179
	 * doRollback() instead.
3180
	 * @param string $fromP Name of the user whose edits to rollback.
3181
	 * @param string $summary Custom summary. Set to default summary if empty.
3182
	 * @param bool $bot If true, mark all reverted edits as bot.
3183
	 *
3184
	 * @param array $resultDetails Contains result-specific array of additional values
3185
	 * @param User $guser The user performing the rollback
3186
	 * @param array|null $tags Change tags to apply to the rollback
3187
	 * Callers are responsible for permission checks
3188
	 * (with ChangeTags::canAddTagsAccompanyingChange)
3189
	 *
3190
	 * @return array
3191
	 */
3192
	public function commitRollback( $fromP, $summary, $bot,
3193
		&$resultDetails, User $guser, $tags = null
3194
	) {
3195
		global $wgUseRCPatrol, $wgContLang;
3196
3197
		$dbw = wfGetDB( DB_MASTER );
3198
3199
		if ( wfReadOnly() ) {
3200
			return [ [ 'readonlytext' ] ];
3201
		}
3202
3203
		// Get the last editor
3204
		$current = $this->getRevision();
3205
		if ( is_null( $current ) ) {
3206
			// Something wrong... no page?
3207
			return [ [ 'notanarticle' ] ];
3208
		}
3209
3210
		$from = str_replace( '_', ' ', $fromP );
3211
		// User name given should match up with the top revision.
3212
		// If the user was deleted then $from should be empty.
3213 View Code Duplication
		if ( $from != $current->getUserText() ) {
3214
			$resultDetails = [ 'current' => $current ];
3215
			return [ [ 'alreadyrolled',
3216
				htmlspecialchars( $this->mTitle->getPrefixedText() ),
3217
				htmlspecialchars( $fromP ),
3218
				htmlspecialchars( $current->getUserText() )
3219
			] ];
3220
		}
3221
3222
		// Get the last edit not by this person...
3223
		// Note: these may not be public values
3224
		$user = intval( $current->getUser( Revision::RAW ) );
3225
		$user_text = $dbw->addQuotes( $current->getUserText( Revision::RAW ) );
0 ignored issues
show
Bug introduced by
It seems like $current->getUserText(\Revision::RAW) targeting Revision::getUserText() can also be of type boolean; however, DatabaseBase::addQuotes() does only seem to accept string|object<Blob>, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
3226
		$s = $dbw->selectRow( 'revision',
3227
			[ 'rev_id', 'rev_timestamp', 'rev_deleted' ],
3228
			[ 'rev_page' => $current->getPage(),
3229
				"rev_user != {$user} OR rev_user_text != {$user_text}"
3230
			], __METHOD__,
3231
			[ 'USE INDEX' => 'page_timestamp',
3232
				'ORDER BY' => 'rev_timestamp DESC' ]
3233
			);
3234
		if ( $s === false ) {
3235
			// No one else ever edited this page
3236
			return [ [ 'cantrollback' ] ];
3237
		} elseif ( $s->rev_deleted & Revision::DELETED_TEXT
3238
			|| $s->rev_deleted & Revision::DELETED_USER
3239
		) {
3240
			// Only admins can see this text
3241
			return [ [ 'notvisiblerev' ] ];
3242
		}
3243
3244
		// Generate the edit summary if necessary
3245
		$target = Revision::newFromId( $s->rev_id, Revision::READ_LATEST );
3246
		if ( empty( $summary ) ) {
3247
			if ( $from == '' ) { // no public user name
3248
				$summary = wfMessage( 'revertpage-nouser' );
3249
			} else {
3250
				$summary = wfMessage( 'revertpage' );
3251
			}
3252
		}
3253
3254
		// Allow the custom summary to use the same args as the default message
3255
		$args = [
3256
			$target->getUserText(), $from, $s->rev_id,
3257
			$wgContLang->timeanddate( wfTimestamp( TS_MW, $s->rev_timestamp ) ),
3258
			$current->getId(), $wgContLang->timeanddate( $current->getTimestamp() )
3259
		];
3260
		if ( $summary instanceof Message ) {
3261
			$summary = $summary->params( $args )->inContentLanguage()->text();
3262
		} else {
3263
			$summary = wfMsgReplaceArgs( $summary, $args );
3264
		}
3265
3266
		// Trim spaces on user supplied text
3267
		$summary = trim( $summary );
3268
3269
		// Truncate for whole multibyte characters.
3270
		$summary = $wgContLang->truncate( $summary, 255 );
3271
3272
		// Save
3273
		$flags = EDIT_UPDATE | EDIT_INTERNAL;
3274
3275
		if ( $guser->isAllowed( 'minoredit' ) ) {
3276
			$flags |= EDIT_MINOR;
3277
		}
3278
3279
		if ( $bot && ( $guser->isAllowedAny( 'markbotedits', 'bot' ) ) ) {
3280
			$flags |= EDIT_FORCE_BOT;
3281
		}
3282
3283
		$targetContent = $target->getContent();
3284
		$changingContentModel = $targetContent->getModel() !== $current->getContentModel();
3285
3286
		// Actually store the edit
3287
		$status = $this->doEditContent(
3288
			$targetContent,
3289
			$summary,
3290
			$flags,
3291
			$target->getId(),
3292
			$guser,
3293
			null,
3294
			$tags
3295
		);
3296
3297
		// Set patrolling and bot flag on the edits, which gets rollbacked.
3298
		// This is done even on edit failure to have patrolling in that case (bug 62157).
3299
		$set = [];
3300
		if ( $bot && $guser->isAllowed( 'markbotedits' ) ) {
3301
			// Mark all reverted edits as bot
3302
			$set['rc_bot'] = 1;
3303
		}
3304
3305
		if ( $wgUseRCPatrol ) {
3306
			// Mark all reverted edits as patrolled
3307
			$set['rc_patrolled'] = 1;
3308
		}
3309
3310
		if ( count( $set ) ) {
3311
			$dbw->update( 'recentchanges', $set,
3312
				[ /* WHERE */
3313
					'rc_cur_id' => $current->getPage(),
3314
					'rc_user_text' => $current->getUserText(),
3315
					'rc_timestamp > ' . $dbw->addQuotes( $s->rev_timestamp ),
3316
				],
3317
				__METHOD__
3318
			);
3319
		}
3320
3321
		if ( !$status->isOK() ) {
3322
			return $status->getErrorsArray();
3323
		}
3324
3325
		// raise error, when the edit is an edit without a new version
3326
		$statusRev = isset( $status->value['revision'] )
3327
			? $status->value['revision']
3328
			: null;
3329 View Code Duplication
		if ( !( $statusRev instanceof Revision ) ) {
3330
			$resultDetails = [ 'current' => $current ];
3331
			return [ [ 'alreadyrolled',
3332
					htmlspecialchars( $this->mTitle->getPrefixedText() ),
3333
					htmlspecialchars( $fromP ),
3334
					htmlspecialchars( $current->getUserText() )
3335
			] ];
3336
		}
3337
3338
		if ( $changingContentModel ) {
3339
			// If the content model changed during the rollback,
3340
			// make sure it gets logged to Special:Log/contentmodel
3341
			$log = new ManualLogEntry( 'contentmodel', 'change' );
3342
			$log->setPerformer( $guser );
3343
			$log->setTarget( $this->mTitle );
3344
			$log->setComment( $summary );
3345
			$log->setParameters( [
3346
				'4::oldmodel' => $current->getContentModel(),
3347
				'5::newmodel' => $targetContent->getModel(),
3348
			] );
3349
3350
			$logId = $log->insert( $dbw );
3351
			$log->publish( $logId );
3352
		}
3353
3354
		$revId = $statusRev->getId();
3355
3356
		Hooks::run( 'ArticleRollbackComplete', [ $this, $guser, $target, $current ] );
3357
3358
		$resultDetails = [
3359
			'summary' => $summary,
3360
			'current' => $current,
3361
			'target' => $target,
3362
			'newid' => $revId
3363
		];
3364
3365
		return [];
3366
	}
3367
3368
	/**
3369
	 * The onArticle*() functions are supposed to be a kind of hooks
3370
	 * which should be called whenever any of the specified actions
3371
	 * are done.
3372
	 *
3373
	 * This is a good place to put code to clear caches, for instance.
3374
	 *
3375
	 * This is called on page move and undelete, as well as edit
3376
	 *
3377
	 * @param Title $title
3378
	 */
3379
	public static function onArticleCreate( Title $title ) {
3380
		// Update existence markers on article/talk tabs...
3381
		$other = $title->getOtherPage();
3382
3383
		$other->purgeSquid();
3384
3385
		$title->touchLinks();
3386
		$title->purgeSquid();
3387
		$title->deleteTitleProtection();
3388
3389
		MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
3390
3391
		if ( $title->getNamespace() == NS_CATEGORY ) {
3392
			// Load the Category object, which will schedule a job to create
3393
			// the category table row if necessary. Checking a replica DB is ok
3394
			// here, in the worst case it'll run an unnecessary recount job on
3395
			// a category that probably doesn't have many members.
3396
			Category::newFromTitle( $title )->getID();
3397
		}
3398
	}
3399
3400
	/**
3401
	 * Clears caches when article is deleted
3402
	 *
3403
	 * @param Title $title
3404
	 */
3405
	public static function onArticleDelete( Title $title ) {
3406
		global $wgContLang;
3407
3408
		// Update existence markers on article/talk tabs...
3409
		$other = $title->getOtherPage();
3410
3411
		$other->purgeSquid();
3412
3413
		$title->touchLinks();
3414
		$title->purgeSquid();
3415
3416
		MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
3417
3418
		// File cache
3419
		HTMLFileCache::clearFileCache( $title );
3420
		InfoAction::invalidateCache( $title );
3421
3422
		// Messages
3423
		if ( $title->getNamespace() == NS_MEDIAWIKI ) {
3424
			MessageCache::singleton()->replace( $title->getDBkey(), false );
3425
3426
			if ( $wgContLang->hasVariants() ) {
3427
				$wgContLang->updateConversionTable( $title );
3428
			}
3429
		}
3430
3431
		// Images
3432
		if ( $title->getNamespace() == NS_FILE ) {
3433
			DeferredUpdates::addUpdate( new HTMLCacheUpdate( $title, 'imagelinks' ) );
3434
		}
3435
3436
		// User talk pages
3437
		if ( $title->getNamespace() == NS_USER_TALK ) {
3438
			$user = User::newFromName( $title->getText(), false );
3439
			if ( $user ) {
3440
				$user->setNewtalk( false );
3441
			}
3442
		}
3443
3444
		// Image redirects
3445
		RepoGroup::singleton()->getLocalRepo()->invalidateImageRedirect( $title );
3446
	}
3447
3448
	/**
3449
	 * Purge caches on page update etc
3450
	 *
3451
	 * @param Title $title
3452
	 * @param Revision|null $revision Revision that was just saved, may be null
3453
	 */
3454
	public static function onArticleEdit( Title $title, Revision $revision = null ) {
3455
		// Invalidate caches of articles which include this page
3456
		DeferredUpdates::addUpdate( new HTMLCacheUpdate( $title, 'templatelinks' ) );
3457
3458
		// Invalidate the caches of all pages which redirect here
3459
		DeferredUpdates::addUpdate( new HTMLCacheUpdate( $title, 'redirect' ) );
3460
3461
		MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
3462
3463
		// Purge CDN for this page only
3464
		$title->purgeSquid();
3465
		// Clear file cache for this page only
3466
		HTMLFileCache::clearFileCache( $title );
3467
3468
		$revid = $revision ? $revision->getId() : null;
3469
		DeferredUpdates::addCallableUpdate( function() use ( $title, $revid ) {
3470
			InfoAction::invalidateCache( $title, $revid );
3471
		} );
3472
	}
3473
3474
	/**#@-*/
3475
3476
	/**
3477
	 * Returns a list of categories this page is a member of.
3478
	 * Results will include hidden categories
3479
	 *
3480
	 * @return TitleArray
3481
	 */
3482
	public function getCategories() {
3483
		$id = $this->getId();
3484
		if ( $id == 0 ) {
3485
			return TitleArray::newFromResult( new FakeResultWrapper( [] ) );
3486
		}
3487
3488
		$dbr = wfGetDB( DB_REPLICA );
3489
		$res = $dbr->select( 'categorylinks',
3490
			[ 'cl_to AS page_title, ' . NS_CATEGORY . ' AS page_namespace' ],
3491
			// Have to do that since DatabaseBase::fieldNamesWithAlias treats numeric indexes
3492
			// as not being aliases, and NS_CATEGORY is numeric
3493
			[ 'cl_from' => $id ],
3494
			__METHOD__ );
3495
3496
		return TitleArray::newFromResult( $res );
0 ignored issues
show
Bug introduced by
It seems like $res defined by $dbr->select('categoryli...m' => $id), __METHOD__) on line 3489 can also be of type boolean; however, TitleArray::newFromResult() does only seem to accept object<ResultWrapper>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
3497
	}
3498
3499
	/**
3500
	 * Returns a list of hidden categories this page is a member of.
3501
	 * Uses the page_props and categorylinks tables.
3502
	 *
3503
	 * @return array Array of Title objects
3504
	 */
3505
	public function getHiddenCategories() {
3506
		$result = [];
3507
		$id = $this->getId();
3508
3509
		if ( $id == 0 ) {
3510
			return [];
3511
		}
3512
3513
		$dbr = wfGetDB( DB_REPLICA );
3514
		$res = $dbr->select( [ 'categorylinks', 'page_props', 'page' ],
3515
			[ 'cl_to' ],
3516
			[ 'cl_from' => $id, 'pp_page=page_id', 'pp_propname' => 'hiddencat',
3517
				'page_namespace' => NS_CATEGORY, 'page_title=cl_to' ],
3518
			__METHOD__ );
3519
3520
		if ( $res !== false ) {
3521
			foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type object<ResultWrapper>|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
3522
				$result[] = Title::makeTitle( NS_CATEGORY, $row->cl_to );
3523
			}
3524
		}
3525
3526
		return $result;
3527
	}
3528
3529
	/**
3530
	 * Return an applicable autosummary if one exists for the given edit.
3531
	 * @param string|null $oldtext The previous text of the page.
3532
	 * @param string|null $newtext The submitted text of the page.
3533
	 * @param int $flags Bitmask: a bitmask of flags submitted for the edit.
3534
	 * @return string An appropriate autosummary, or an empty string.
3535
	 *
3536
	 * @deprecated since 1.21, use ContentHandler::getAutosummary() instead
3537
	 */
3538
	public static function getAutosummary( $oldtext, $newtext, $flags ) {
3539
		// NOTE: stub for backwards-compatibility. assumes the given text is
3540
		// wikitext. will break horribly if it isn't.
3541
3542
		ContentHandler::deprecated( __METHOD__, '1.21' );
3543
3544
		$handler = ContentHandler::getForModelID( CONTENT_MODEL_WIKITEXT );
3545
		$oldContent = is_null( $oldtext ) ? null : $handler->unserializeContent( $oldtext );
3546
		$newContent = is_null( $newtext ) ? null : $handler->unserializeContent( $newtext );
3547
3548
		return $handler->getAutosummary( $oldContent, $newContent, $flags );
3549
	}
3550
3551
	/**
3552
	 * Auto-generates a deletion reason
3553
	 *
3554
	 * @param bool &$hasHistory Whether the page has a history
3555
	 * @return string|bool String containing deletion reason or empty string, or boolean false
3556
	 *    if no revision occurred
3557
	 */
3558
	public function getAutoDeleteReason( &$hasHistory ) {
3559
		return $this->getContentHandler()->getAutoDeleteReason( $this->getTitle(), $hasHistory );
3560
	}
3561
3562
	/**
3563
	 * Update all the appropriate counts in the category table, given that
3564
	 * we've added the categories $added and deleted the categories $deleted.
3565
	 *
3566
	 * @param array $added The names of categories that were added
3567
	 * @param array $deleted The names of categories that were deleted
3568
	 * @param integer $id Page ID (this should be the original deleted page ID)
3569
	 */
3570
	public function updateCategoryCounts( array $added, array $deleted, $id = 0 ) {
3571
		$id = $id ?: $this->getId();
3572
		$dbw = wfGetDB( DB_MASTER );
3573
		$method = __METHOD__;
3574
		// Do this at the end of the commit to reduce lock wait timeouts
3575
		$dbw->onTransactionPreCommitOrIdle(
3576
			function () use ( $dbw, $added, $deleted, $id, $method ) {
3577
				$ns = $this->getTitle()->getNamespace();
3578
3579
				$addFields = [ 'cat_pages = cat_pages + 1' ];
3580
				$removeFields = [ 'cat_pages = cat_pages - 1' ];
3581
				if ( $ns == NS_CATEGORY ) {
3582
					$addFields[] = 'cat_subcats = cat_subcats + 1';
3583
					$removeFields[] = 'cat_subcats = cat_subcats - 1';
3584
				} elseif ( $ns == NS_FILE ) {
3585
					$addFields[] = 'cat_files = cat_files + 1';
3586
					$removeFields[] = 'cat_files = cat_files - 1';
3587
				}
3588
3589
				if ( count( $added ) ) {
3590
					$existingAdded = $dbw->selectFieldValues(
3591
						'category',
3592
						'cat_title',
3593
						[ 'cat_title' => $added ],
3594
						$method
3595
					);
3596
3597
					// For category rows that already exist, do a plain
3598
					// UPDATE instead of INSERT...ON DUPLICATE KEY UPDATE
3599
					// to avoid creating gaps in the cat_id sequence.
3600
					if ( count( $existingAdded ) ) {
3601
						$dbw->update(
3602
							'category',
3603
							$addFields,
3604
							[ 'cat_title' => $existingAdded ],
3605
							$method
3606
						);
3607
					}
3608
3609
					$missingAdded = array_diff( $added, $existingAdded );
3610
					if ( count( $missingAdded ) ) {
3611
						$insertRows = [];
3612
						foreach ( $missingAdded as $cat ) {
3613
							$insertRows[] = [
3614
								'cat_title'   => $cat,
3615
								'cat_pages'   => 1,
3616
								'cat_subcats' => ( $ns == NS_CATEGORY ) ? 1 : 0,
3617
								'cat_files'   => ( $ns == NS_FILE ) ? 1 : 0,
3618
							];
3619
						}
3620
						$dbw->upsert(
3621
							'category',
3622
							$insertRows,
3623
							[ 'cat_title' ],
3624
							$addFields,
3625
							$method
3626
						);
3627
					}
3628
				}
3629
3630
				if ( count( $deleted ) ) {
3631
					$dbw->update(
3632
						'category',
3633
						$removeFields,
3634
						[ 'cat_title' => $deleted ],
3635
						$method
3636
					);
3637
				}
3638
3639
				foreach ( $added as $catName ) {
3640
					$cat = Category::newFromName( $catName );
3641
					Hooks::run( 'CategoryAfterPageAdded', [ $cat, $this ] );
3642
				}
3643
3644
				foreach ( $deleted as $catName ) {
3645
					$cat = Category::newFromName( $catName );
3646
					Hooks::run( 'CategoryAfterPageRemoved', [ $cat, $this, $id ] );
3647
				}
3648
3649
				// Refresh counts on categories that should be empty now, to
3650
				// trigger possible deletion. Check master for the most
3651
				// up-to-date cat_pages.
3652
				if ( count( $deleted ) ) {
3653
					$rows = $dbw->select(
3654
						'category',
3655
						[ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ],
3656
						[ 'cat_title' => $deleted, 'cat_pages <= 0' ],
3657
						$method
3658
					);
3659
					foreach ( $rows as $row ) {
0 ignored issues
show
Bug introduced by
The expression $rows of type object<ResultWrapper>|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
3660
						$cat = Category::newFromRow( $row );
3661
						$cat->refreshCounts();
3662
					}
3663
				}
3664
			}
3665
		);
3666
	}
3667
3668
	/**
3669
	 * Opportunistically enqueue link update jobs given fresh parser output if useful
3670
	 *
3671
	 * @param ParserOutput $parserOutput Current version page output
3672
	 * @since 1.25
3673
	 */
3674
	public function triggerOpportunisticLinksUpdate( ParserOutput $parserOutput ) {
3675
		if ( wfReadOnly() ) {
3676
			return;
3677
		}
3678
3679
		if ( !Hooks::run( 'OpportunisticLinksUpdate',
3680
			[ $this, $this->mTitle, $parserOutput ]
3681
		) ) {
3682
			return;
3683
		}
3684
3685
		$config = RequestContext::getMain()->getConfig();
3686
3687
		$params = [
3688
			'isOpportunistic' => true,
3689
			'rootJobTimestamp' => $parserOutput->getCacheTime()
3690
		];
3691
3692
		if ( $this->mTitle->areRestrictionsCascading() ) {
3693
			// If the page is cascade protecting, the links should really be up-to-date
3694
			JobQueueGroup::singleton()->lazyPush(
3695
				RefreshLinksJob::newPrioritized( $this->mTitle, $params )
3696
			);
3697
		} elseif ( !$config->get( 'MiserMode' ) && $parserOutput->hasDynamicContent() ) {
3698
			// Assume the output contains "dynamic" time/random based magic words.
3699
			// Only update pages that expired due to dynamic content and NOT due to edits
3700
			// to referenced templates/files. When the cache expires due to dynamic content,
3701
			// page_touched is unchanged. We want to avoid triggering redundant jobs due to
3702
			// views of pages that were just purged via HTMLCacheUpdateJob. In that case, the
3703
			// template/file edit already triggered recursive RefreshLinksJob jobs.
3704
			if ( $this->getLinksTimestamp() > $this->getTouched() ) {
3705
				// If a page is uncacheable, do not keep spamming a job for it.
3706
				// Although it would be de-duplicated, it would still waste I/O.
3707
				$cache = ObjectCache::getLocalClusterInstance();
3708
				$key = $cache->makeKey( 'dynamic-linksupdate', 'last', $this->getId() );
3709
				$ttl = max( $parserOutput->getCacheExpiry(), 3600 );
3710
				if ( $cache->add( $key, time(), $ttl ) ) {
3711
					JobQueueGroup::singleton()->lazyPush(
3712
						RefreshLinksJob::newDynamic( $this->mTitle, $params )
3713
					);
3714
				}
3715
			}
3716
		}
3717
	}
3718
3719
	/**
3720
	 * Returns a list of updates to be performed when this page is deleted. The
3721
	 * updates should remove any information about this page from secondary data
3722
	 * stores such as links tables.
3723
	 *
3724
	 * @param Content|null $content Optional Content object for determining the
3725
	 *   necessary updates.
3726
	 * @return DataUpdate[]
3727
	 */
3728
	public function getDeletionUpdates( Content $content = null ) {
3729
		if ( !$content ) {
3730
			// load content object, which may be used to determine the necessary updates.
3731
			// XXX: the content may not be needed to determine the updates.
3732
			try {
3733
				$content = $this->getContent( Revision::RAW );
3734
			} catch ( Exception $ex ) {
3735
				// If we can't load the content, something is wrong. Perhaps that's why
3736
				// the user is trying to delete the page, so let's not fail in that case.
3737
				// Note that doDeleteArticleReal() will already have logged an issue with
3738
				// loading the content.
3739
			}
3740
		}
3741
3742
		if ( !$content ) {
3743
			$updates = [];
3744
		} else {
3745
			$updates = $content->getDeletionUpdates( $this );
3746
		}
3747
3748
		Hooks::run( 'WikiPageDeletionUpdates', [ $this, $content, &$updates ] );
3749
		return $updates;
3750
	}
3751
3752
	/**
3753
	 * Whether this content displayed on this page
3754
	 * comes from the local database
3755
	 *
3756
	 * @since 1.28
3757
	 * @return bool
3758
	 */
3759
	public function isLocal() {
3760
		return true;
3761
	}
3762
}
3763