Completed
Branch master (174b3a)
by
unknown
26:51
created

WikiPage::doDeleteArticleReal()   C

Complexity

Conditions 12
Paths 55

Size

Total Lines 172
Code Lines 103

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 12
eloc 103
nc 55
nop 6
dl 0
loc 172
rs 5.034
c 0
b 0
f 0

How to fix   Long Method    Complexity   

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
			$cache = ObjectCache::getMainWANInstance();
497
498
			return $cache->getWithSetCallback(
499
				$cache->makeKey( 'page', 'content-model', $this->getLatest() ),
500
				$cache::TTL_MONTH,
501
				function () {
502
					$rev = $this->getRevision();
503
					if ( $rev ) {
504
						// Look at the revision's actual content model
505
						return $rev->getContentModel();
506
					} else {
507
						$title = $this->mTitle->getPrefixedDBkey();
508
						wfWarn( "Page $title exists but has no (visible) revisions!" );
509
						return $this->mTitle->getContentModel();
510
					}
511
				}
512
			);
513
		}
514
515
		// use the default model for this page
516
		return $this->mTitle->getContentModel();
517
	}
518
519
	/**
520
	 * Loads page_touched and returns a value indicating if it should be used
521
	 * @return bool True if this page exists and is not a redirect
522
	 */
523
	public function checkTouched() {
524
		if ( !$this->mDataLoaded ) {
525
			$this->loadPageData();
526
		}
527
		return ( $this->mId && !$this->mIsRedirect );
528
	}
529
530
	/**
531
	 * Get the page_touched field
532
	 * @return string Containing GMT timestamp
533
	 */
534
	public function getTouched() {
535
		if ( !$this->mDataLoaded ) {
536
			$this->loadPageData();
537
		}
538
		return $this->mTouched;
539
	}
540
541
	/**
542
	 * Get the page_links_updated field
543
	 * @return string|null Containing GMT timestamp
544
	 */
545
	public function getLinksTimestamp() {
546
		if ( !$this->mDataLoaded ) {
547
			$this->loadPageData();
548
		}
549
		return $this->mLinksUpdated;
550
	}
551
552
	/**
553
	 * Get the page_latest field
554
	 * @return int The rev_id of current revision
555
	 */
556
	public function getLatest() {
557
		if ( !$this->mDataLoaded ) {
558
			$this->loadPageData();
559
		}
560
		return (int)$this->mLatest;
561
	}
562
563
	/**
564
	 * Get the Revision object of the oldest revision
565
	 * @return Revision|null
566
	 */
567
	public function getOldestRevision() {
568
569
		// Try using the replica DB first, then try the master
570
		$continue = 2;
571
		$db = wfGetDB( DB_REPLICA );
572
		$revSelectFields = Revision::selectFields();
573
574
		$row = null;
575
		while ( $continue ) {
576
			$row = $db->selectRow(
577
				[ 'page', 'revision' ],
578
				$revSelectFields,
579
				[
580
					'page_namespace' => $this->mTitle->getNamespace(),
581
					'page_title' => $this->mTitle->getDBkey(),
582
					'rev_page = page_id'
583
				],
584
				__METHOD__,
585
				[
586
					'ORDER BY' => 'rev_timestamp ASC'
587
				]
588
			);
589
590
			if ( $row ) {
591
				$continue = 0;
592
			} else {
593
				$db = wfGetDB( DB_MASTER );
594
				$continue--;
595
			}
596
		}
597
598
		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...
599
	}
600
601
	/**
602
	 * Loads everything except the text
603
	 * This isn't necessary for all uses, so it's only done if needed.
604
	 */
605
	protected function loadLastEdit() {
606
		if ( $this->mLastRevision !== null ) {
607
			return; // already loaded
608
		}
609
610
		$latest = $this->getLatest();
611
		if ( !$latest ) {
612
			return; // page doesn't exist or is missing page_latest info
613
		}
614
615
		if ( $this->mDataLoadedFrom == self::READ_LOCKING ) {
616
			// Bug 37225: if session S1 loads the page row FOR UPDATE, the result always
617
			// includes the latest changes committed. This is true even within REPEATABLE-READ
618
			// transactions, where S1 normally only sees changes committed before the first S1
619
			// SELECT. Thus we need S1 to also gets the revision row FOR UPDATE; otherwise, it
620
			// may not find it since a page row UPDATE and revision row INSERT by S2 may have
621
			// happened after the first S1 SELECT.
622
			// http://dev.mysql.com/doc/refman/5.0/en/set-transaction.html#isolevel_repeatable-read
623
			$flags = Revision::READ_LOCKING;
624
			$revision = Revision::newFromPageId( $this->getId(), $latest, $flags );
625
		} elseif ( $this->mDataLoadedFrom == self::READ_LATEST ) {
626
			// Bug T93976: if page_latest was loaded from the master, fetch the
627
			// revision from there as well, as it may not exist yet on a replica DB.
628
			// Also, this keeps the queries in the same REPEATABLE-READ snapshot.
629
			$flags = Revision::READ_LATEST;
630
			$revision = Revision::newFromPageId( $this->getId(), $latest, $flags );
631
		} else {
632
			$dbr = wfGetDB( DB_REPLICA );
633
			$revision = Revision::newKnownCurrent( $dbr, $this->getId(), $latest );
634
		}
635
636
		if ( $revision ) { // sanity
637
			$this->setLastEdit( $revision );
0 ignored issues
show
Bug introduced by
It seems like $revision can also be of type boolean; however, WikiPage::setLastEdit() does only seem to accept object<Revision>, 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...
638
		}
639
	}
640
641
	/**
642
	 * Set the latest revision
643
	 * @param Revision $revision
644
	 */
645
	protected function setLastEdit( Revision $revision ) {
646
		$this->mLastRevision = $revision;
647
		$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...
648
	}
649
650
	/**
651
	 * Get the latest revision
652
	 * @return Revision|null
653
	 */
654
	public function getRevision() {
655
		$this->loadLastEdit();
656
		if ( $this->mLastRevision ) {
657
			return $this->mLastRevision;
658
		}
659
		return null;
660
	}
661
662
	/**
663
	 * Get the content of the current revision. No side-effects...
664
	 *
665
	 * @param int $audience One of:
666
	 *   Revision::FOR_PUBLIC       to be displayed to all users
667
	 *   Revision::FOR_THIS_USER    to be displayed to $wgUser
668
	 *   Revision::RAW              get the text regardless of permissions
669
	 * @param User $user User object to check for, only if FOR_THIS_USER is passed
670
	 *   to the $audience parameter
671
	 * @return Content|null The content of the current revision
672
	 *
673
	 * @since 1.21
674
	 */
675 View Code Duplication
	public function getContent( $audience = Revision::FOR_PUBLIC, User $user = null ) {
676
		$this->loadLastEdit();
677
		if ( $this->mLastRevision ) {
678
			return $this->mLastRevision->getContent( $audience, $user );
679
		}
680
		return null;
681
	}
682
683
	/**
684
	 * Get the text of the current revision. No side-effects...
685
	 *
686
	 * @param int $audience One of:
687
	 *   Revision::FOR_PUBLIC       to be displayed to all users
688
	 *   Revision::FOR_THIS_USER    to be displayed to the given user
689
	 *   Revision::RAW              get the text regardless of permissions
690
	 * @param User $user User object to check for, only if FOR_THIS_USER is passed
691
	 *   to the $audience parameter
692
	 * @return string|bool The text of the current revision
693
	 * @deprecated since 1.21, getContent() should be used instead.
694
	 */
695
	public function getText( $audience = Revision::FOR_PUBLIC, User $user = null ) {
696
		ContentHandler::deprecated( __METHOD__, '1.21' );
697
698
		$this->loadLastEdit();
699
		if ( $this->mLastRevision ) {
700
			return $this->mLastRevision->getText( $audience, $user );
701
		}
702
		return false;
703
	}
704
705
	/**
706
	 * @return string MW timestamp of last article revision
707
	 */
708
	public function getTimestamp() {
709
		// Check if the field has been filled by WikiPage::setTimestamp()
710
		if ( !$this->mTimestamp ) {
711
			$this->loadLastEdit();
712
		}
713
714
		return wfTimestamp( TS_MW, $this->mTimestamp );
715
	}
716
717
	/**
718
	 * Set the page timestamp (use only to avoid DB queries)
719
	 * @param string $ts MW timestamp of last article revision
720
	 * @return void
721
	 */
722
	public function setTimestamp( $ts ) {
723
		$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...
724
	}
725
726
	/**
727
	 * @param int $audience One of:
728
	 *   Revision::FOR_PUBLIC       to be displayed to all users
729
	 *   Revision::FOR_THIS_USER    to be displayed to the given user
730
	 *   Revision::RAW              get the text regardless of permissions
731
	 * @param User $user User object to check for, only if FOR_THIS_USER is passed
732
	 *   to the $audience parameter
733
	 * @return int User ID for the user that made the last article revision
734
	 */
735 View Code Duplication
	public function getUser( $audience = Revision::FOR_PUBLIC, User $user = null ) {
736
		$this->loadLastEdit();
737
		if ( $this->mLastRevision ) {
738
			return $this->mLastRevision->getUser( $audience, $user );
739
		} else {
740
			return -1;
741
		}
742
	}
743
744
	/**
745
	 * Get the User object of the user who created the page
746
	 * @param int $audience One of:
747
	 *   Revision::FOR_PUBLIC       to be displayed to all users
748
	 *   Revision::FOR_THIS_USER    to be displayed to the given user
749
	 *   Revision::RAW              get the text regardless of permissions
750
	 * @param User $user User object to check for, only if FOR_THIS_USER is passed
751
	 *   to the $audience parameter
752
	 * @return User|null
753
	 */
754
	public function getCreator( $audience = Revision::FOR_PUBLIC, User $user = null ) {
755
		$revision = $this->getOldestRevision();
756
		if ( $revision ) {
757
			$userName = $revision->getUserText( $audience, $user );
758
			return User::newFromName( $userName, false );
0 ignored issues
show
Bug introduced by
It seems like $userName defined by $revision->getUserText($audience, $user) on line 757 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...
759
		} else {
760
			return null;
761
		}
762
	}
763
764
	/**
765
	 * @param int $audience One of:
766
	 *   Revision::FOR_PUBLIC       to be displayed to all users
767
	 *   Revision::FOR_THIS_USER    to be displayed to the given user
768
	 *   Revision::RAW              get the text regardless of permissions
769
	 * @param User $user User object to check for, only if FOR_THIS_USER is passed
770
	 *   to the $audience parameter
771
	 * @return string Username of the user that made the last article revision
772
	 */
773 View Code Duplication
	public function getUserText( $audience = Revision::FOR_PUBLIC, User $user = null ) {
774
		$this->loadLastEdit();
775
		if ( $this->mLastRevision ) {
776
			return $this->mLastRevision->getUserText( $audience, $user );
777
		} else {
778
			return '';
779
		}
780
	}
781
782
	/**
783
	 * @param int $audience One of:
784
	 *   Revision::FOR_PUBLIC       to be displayed to all users
785
	 *   Revision::FOR_THIS_USER    to be displayed to the given user
786
	 *   Revision::RAW              get the text regardless of permissions
787
	 * @param User $user User object to check for, only if FOR_THIS_USER is passed
788
	 *   to the $audience parameter
789
	 * @return string Comment stored for the last article revision
790
	 */
791 View Code Duplication
	public function getComment( $audience = Revision::FOR_PUBLIC, User $user = null ) {
792
		$this->loadLastEdit();
793
		if ( $this->mLastRevision ) {
794
			return $this->mLastRevision->getComment( $audience, $user );
795
		} else {
796
			return '';
797
		}
798
	}
799
800
	/**
801
	 * Returns true if last revision was marked as "minor edit"
802
	 *
803
	 * @return bool Minor edit indicator for the last article revision.
804
	 */
805
	public function getMinorEdit() {
806
		$this->loadLastEdit();
807
		if ( $this->mLastRevision ) {
808
			return $this->mLastRevision->isMinor();
809
		} else {
810
			return false;
811
		}
812
	}
813
814
	/**
815
	 * Determine whether a page would be suitable for being counted as an
816
	 * article in the site_stats table based on the title & its content
817
	 *
818
	 * @param object|bool $editInfo (false): object returned by prepareTextForEdit(),
819
	 *   if false, the current database state will be used
820
	 * @return bool
821
	 */
822
	public function isCountable( $editInfo = false ) {
823
		global $wgArticleCountMethod;
824
825
		if ( !$this->mTitle->isContentPage() ) {
826
			return false;
827
		}
828
829
		if ( $editInfo ) {
830
			$content = $editInfo->pstContent;
831
		} else {
832
			$content = $this->getContent();
833
		}
834
835
		if ( !$content || $content->isRedirect() ) {
836
			return false;
837
		}
838
839
		$hasLinks = null;
840
841
		if ( $wgArticleCountMethod === 'link' ) {
842
			// nasty special case to avoid re-parsing to detect links
843
844
			if ( $editInfo ) {
845
				// ParserOutput::getLinks() is a 2D array of page links, so
846
				// to be really correct we would need to recurse in the array
847
				// but the main array should only have items in it if there are
848
				// links.
849
				$hasLinks = (bool)count( $editInfo->output->getLinks() );
850
			} else {
851
				$hasLinks = (bool)wfGetDB( DB_REPLICA )->selectField( 'pagelinks', 1,
852
					[ 'pl_from' => $this->getId() ], __METHOD__ );
853
			}
854
		}
855
856
		return $content->isCountable( $hasLinks );
857
	}
858
859
	/**
860
	 * If this page is a redirect, get its target
861
	 *
862
	 * The target will be fetched from the redirect table if possible.
863
	 * If this page doesn't have an entry there, call insertRedirect()
864
	 * @return Title|null Title object, or null if this page is not a redirect
865
	 */
866
	public function getRedirectTarget() {
867
		if ( !$this->mTitle->isRedirect() ) {
868
			return null;
869
		}
870
871
		if ( $this->mRedirectTarget !== null ) {
872
			return $this->mRedirectTarget;
873
		}
874
875
		// Query the redirect table
876
		$dbr = wfGetDB( DB_REPLICA );
877
		$row = $dbr->selectRow( 'redirect',
878
			[ 'rd_namespace', 'rd_title', 'rd_fragment', 'rd_interwiki' ],
879
			[ 'rd_from' => $this->getId() ],
880
			__METHOD__
881
		);
882
883
		// rd_fragment and rd_interwiki were added later, populate them if empty
884
		if ( $row && !is_null( $row->rd_fragment ) && !is_null( $row->rd_interwiki ) ) {
885
			$this->mRedirectTarget = Title::makeTitle(
886
				$row->rd_namespace, $row->rd_title,
887
				$row->rd_fragment, $row->rd_interwiki
888
			);
889
			return $this->mRedirectTarget;
890
		}
891
892
		// This page doesn't have an entry in the redirect table
893
		$this->mRedirectTarget = $this->insertRedirect();
894
		return $this->mRedirectTarget;
895
	}
896
897
	/**
898
	 * Insert an entry for this page into the redirect table if the content is a redirect
899
	 *
900
	 * The database update will be deferred via DeferredUpdates
901
	 *
902
	 * Don't call this function directly unless you know what you're doing.
903
	 * @return Title|null Title object or null if not a redirect
904
	 */
905
	public function insertRedirect() {
906
		$content = $this->getContent();
907
		$retval = $content ? $content->getUltimateRedirectTarget() : null;
908
		if ( !$retval ) {
909
			return null;
910
		}
911
912
		// Update the DB post-send if the page has not cached since now
913
		$that = $this;
914
		$latest = $this->getLatest();
915
		DeferredUpdates::addCallableUpdate(
916
			function () use ( $that, $retval, $latest ) {
917
				$that->insertRedirectEntry( $retval, $latest );
918
			},
919
			DeferredUpdates::POSTSEND,
920
			wfGetDB( DB_MASTER )
921
		);
922
923
		return $retval;
924
	}
925
926
	/**
927
	 * Insert or update the redirect table entry for this page to indicate it redirects to $rt
928
	 * @param Title $rt Redirect target
929
	 * @param int|null $oldLatest Prior page_latest for check and set
930
	 */
931
	public function insertRedirectEntry( Title $rt, $oldLatest = null ) {
932
		$dbw = wfGetDB( DB_MASTER );
933
		$dbw->startAtomic( __METHOD__ );
934
935
		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...
936
			$dbw->replace( 'redirect',
937
				[ 'rd_from' ],
938
				[
939
					'rd_from' => $this->getId(),
940
					'rd_namespace' => $rt->getNamespace(),
941
					'rd_title' => $rt->getDBkey(),
942
					'rd_fragment' => $rt->getFragment(),
943
					'rd_interwiki' => $rt->getInterwiki(),
944
				],
945
				__METHOD__
946
			);
947
		}
948
949
		$dbw->endAtomic( __METHOD__ );
950
	}
951
952
	/**
953
	 * Get the Title object or URL this page redirects to
954
	 *
955
	 * @return bool|Title|string False, Title of in-wiki target, or string with URL
956
	 */
957
	public function followRedirect() {
958
		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...
959
	}
960
961
	/**
962
	 * Get the Title object or URL to use for a redirect. We use Title
963
	 * objects for same-wiki, non-special redirects and URLs for everything
964
	 * else.
965
	 * @param Title $rt Redirect target
966
	 * @return bool|Title|string False, Title object of local target, or string with URL
967
	 */
968
	public function getRedirectURL( $rt ) {
969
		if ( !$rt ) {
970
			return false;
971
		}
972
973
		if ( $rt->isExternal() ) {
974
			if ( $rt->isLocal() ) {
975
				// Offsite wikis need an HTTP redirect.
976
				// This can be hard to reverse and may produce loops,
977
				// so they may be disabled in the site configuration.
978
				$source = $this->mTitle->getFullURL( 'redirect=no' );
979
				return $rt->getFullURL( [ 'rdfrom' => $source ] );
980
			} else {
981
				// External pages without "local" bit set are not valid
982
				// redirect targets
983
				return false;
984
			}
985
		}
986
987
		if ( $rt->isSpecialPage() ) {
988
			// Gotta handle redirects to special pages differently:
989
			// Fill the HTTP response "Location" header and ignore the rest of the page we're on.
990
			// Some pages are not valid targets.
991
			if ( $rt->isValidRedirectTarget() ) {
992
				return $rt->getFullURL();
993
			} else {
994
				return false;
995
			}
996
		}
997
998
		return $rt;
999
	}
1000
1001
	/**
1002
	 * Get a list of users who have edited this article, not including the user who made
1003
	 * the most recent revision, which you can get from $article->getUser() if you want it
1004
	 * @return UserArrayFromResult
1005
	 */
1006
	public function getContributors() {
1007
		// @todo FIXME: This is expensive; cache this info somewhere.
1008
1009
		$dbr = wfGetDB( DB_REPLICA );
1010
1011
		if ( $dbr->implicitGroupby() ) {
1012
			$realNameField = 'user_real_name';
1013
		} else {
1014
			$realNameField = 'MIN(user_real_name) AS user_real_name';
1015
		}
1016
1017
		$tables = [ 'revision', 'user' ];
1018
1019
		$fields = [
1020
			'user_id' => 'rev_user',
1021
			'user_name' => 'rev_user_text',
1022
			$realNameField,
1023
			'timestamp' => 'MAX(rev_timestamp)',
1024
		];
1025
1026
		$conds = [ 'rev_page' => $this->getId() ];
1027
1028
		// The user who made the top revision gets credited as "this page was last edited by
1029
		// John, based on contributions by Tom, Dick and Harry", so don't include them twice.
1030
		$user = $this->getUser();
1031
		if ( $user ) {
1032
			$conds[] = "rev_user != $user";
1033
		} else {
1034
			$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, Database::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...
1035
		}
1036
1037
		// Username hidden?
1038
		$conds[] = "{$dbr->bitAnd( 'rev_deleted', Revision::DELETED_USER )} = 0";
1039
1040
		$jconds = [
1041
			'user' => [ 'LEFT JOIN', 'rev_user = user_id' ],
1042
		];
1043
1044
		$options = [
1045
			'GROUP BY' => [ 'rev_user', 'rev_user_text' ],
1046
			'ORDER BY' => 'timestamp DESC',
1047
		];
1048
1049
		$res = $dbr->select( $tables, $fields, $conds, __METHOD__, $options, $jconds );
1050
		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 1049 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...
1051
	}
1052
1053
	/**
1054
	 * Should the parser cache be used?
1055
	 *
1056
	 * @param ParserOptions $parserOptions ParserOptions to check
1057
	 * @param int $oldId
1058
	 * @return bool
1059
	 */
1060
	public function shouldCheckParserCache( ParserOptions $parserOptions, $oldId ) {
1061
		return $parserOptions->getStubThreshold() == 0
1062
			&& $this->exists()
1063
			&& ( $oldId === null || $oldId === 0 || $oldId === $this->getLatest() )
1064
			&& $this->getContentHandler()->isParserCacheSupported();
1065
	}
1066
1067
	/**
1068
	 * Get a ParserOutput for the given ParserOptions and revision ID.
1069
	 *
1070
	 * The parser cache will be used if possible. Cache misses that result
1071
	 * in parser runs are debounced with PoolCounter.
1072
	 *
1073
	 * @since 1.19
1074
	 * @param ParserOptions $parserOptions ParserOptions to use for the parse operation
1075
	 * @param null|int      $oldid Revision ID to get the text from, passing null or 0 will
1076
	 *                             get the current revision (default value)
1077
	 * @param bool          $forceParse Force reindexing, regardless of cache settings
1078
	 * @return bool|ParserOutput ParserOutput or false if the revision was not found
1079
	 */
1080
	public function getParserOutput(
1081
		ParserOptions $parserOptions, $oldid = null, $forceParse = false
1082
	) {
1083
		$useParserCache =
1084
			( !$forceParse ) && $this->shouldCheckParserCache( $parserOptions, $oldid );
1085
		wfDebug( __METHOD__ .
1086
			': using parser cache: ' . ( $useParserCache ? 'yes' : 'no' ) . "\n" );
1087
		if ( $parserOptions->getStubThreshold() ) {
1088
			wfIncrStats( 'pcache.miss.stub' );
1089
		}
1090
1091
		if ( $useParserCache ) {
1092
			$parserOutput = ParserCache::singleton()->get( $this, $parserOptions );
1093
			if ( $parserOutput !== false ) {
1094
				return $parserOutput;
1095
			}
1096
		}
1097
1098
		if ( $oldid === null || $oldid === 0 ) {
1099
			$oldid = $this->getLatest();
1100
		}
1101
1102
		$pool = new PoolWorkArticleView( $this, $parserOptions, $oldid, $useParserCache );
1103
		$pool->execute();
1104
1105
		return $pool->getParserOutput();
1106
	}
1107
1108
	/**
1109
	 * Do standard deferred updates after page view (existing or missing page)
1110
	 * @param User $user The relevant user
1111
	 * @param int $oldid Revision id being viewed; if not given or 0, latest revision is assumed
1112
	 */
1113
	public function doViewUpdates( User $user, $oldid = 0 ) {
1114
		if ( wfReadOnly() ) {
1115
			return;
1116
		}
1117
1118
		Hooks::run( 'PageViewUpdates', [ $this, $user ] );
1119
		// Update newtalk / watchlist notification status
1120
		try {
1121
			$user->clearNotification( $this->mTitle, $oldid );
1122
		} catch ( DBError $e ) {
1123
			// Avoid outage if the master is not reachable
1124
			MWExceptionHandler::logException( $e );
1125
		}
1126
	}
1127
1128
	/**
1129
	 * Perform the actions of a page purging
1130
	 * @param integer $flags Bitfield of WikiPage::PURGE_* constants
1131
	 * @return bool
1132
	 */
1133
	public function doPurge( $flags = self::PURGE_ALL ) {
1134
		if ( !Hooks::run( 'ArticlePurge', [ &$this ] ) ) {
1135
			return false;
1136
		}
1137
1138
		if ( ( $flags & self::PURGE_GLOBAL_PCACHE ) == self::PURGE_GLOBAL_PCACHE ) {
1139
			// Set page_touched in the database to invalidate all DC caches
1140
			$this->mTitle->invalidateCache();
1141
		} elseif ( ( $flags & self::PURGE_CLUSTER_PCACHE ) == self::PURGE_CLUSTER_PCACHE ) {
1142
			// Delete the parser options key in the local cluster to invalidate the DC cache
1143
			ParserCache::singleton()->deleteOptionsKey( $this );
1144
			// Avoid sending HTTP 304s in ViewAction to the client who just issued the purge
1145
			$cache = ObjectCache::getLocalClusterInstance();
1146
			$cache->set(
1147
				$cache->makeKey( 'page', 'last-dc-purge', $this->getId() ),
1148
				wfTimestamp( TS_MW ),
1149
				$cache::TTL_HOUR
1150
			);
1151
		}
1152
1153
		if ( ( $flags & self::PURGE_CDN_CACHE ) == self::PURGE_CDN_CACHE ) {
1154
			// Clear any HTML file cache
1155
			HTMLFileCache::clearFileCache( $this->getTitle() );
1156
			// Send purge after any page_touched above update was committed
1157
			DeferredUpdates::addUpdate(
1158
				new CdnCacheUpdate( $this->mTitle->getCdnUrls() ),
1159
				DeferredUpdates::PRESEND
1160
			);
1161
		}
1162
1163
		if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) {
1164
			// @todo move this logic to MessageCache
1165
			if ( $this->exists() ) {
1166
				// NOTE: use transclusion text for messages.
1167
				//       This is consistent with  MessageCache::getMsgFromNamespace()
1168
1169
				$content = $this->getContent();
1170
				$text = $content === null ? null : $content->getWikitextForTransclusion();
1171
1172
				if ( $text === null ) {
1173
					$text = false;
1174
				}
1175
			} else {
1176
				$text = false;
1177
			}
1178
1179
			MessageCache::singleton()->replace( $this->mTitle->getDBkey(), $text );
1180
		}
1181
1182
		return true;
1183
	}
1184
1185
	/**
1186
	 * Get the last time a user explicitly purged the page via action=purge
1187
	 *
1188
	 * @return string|bool TS_MW timestamp or false
1189
	 * @since 1.28
1190
	 */
1191
	public function getLastPurgeTimestamp() {
1192
		$cache = ObjectCache::getLocalClusterInstance();
1193
1194
		return $cache->get( $cache->makeKey( 'page', 'last-dc-purge', $this->getId() ) );
1195
	}
1196
1197
	/**
1198
	 * Insert a new empty page record for this article.
1199
	 * This *must* be followed up by creating a revision
1200
	 * and running $this->updateRevisionOn( ... );
1201
	 * or else the record will be left in a funky state.
1202
	 * Best if all done inside a transaction.
1203
	 *
1204
	 * @param IDatabase $dbw
1205
	 * @param int|null $pageId Custom page ID that will be used for the insert statement
1206
	 *
1207
	 * @return bool|int The newly created page_id key; false if the row was not
1208
	 *   inserted, e.g. because the title already existed or because the specified
1209
	 *   page ID is already in use.
1210
	 */
1211
	public function insertOn( $dbw, $pageId = null ) {
1212
		$pageIdForInsert = $pageId ?: $dbw->nextSequenceValue( 'page_page_id_seq' );
1213
		$dbw->insert(
1214
			'page',
1215
			[
1216
				'page_id'           => $pageIdForInsert,
1217
				'page_namespace'    => $this->mTitle->getNamespace(),
1218
				'page_title'        => $this->mTitle->getDBkey(),
1219
				'page_restrictions' => '',
1220
				'page_is_redirect'  => 0, // Will set this shortly...
1221
				'page_is_new'       => 1,
1222
				'page_random'       => wfRandom(),
1223
				'page_touched'      => $dbw->timestamp(),
1224
				'page_latest'       => 0, // Fill this in shortly...
1225
				'page_len'          => 0, // Fill this in shortly...
1226
			],
1227
			__METHOD__,
1228
			'IGNORE'
1229
		);
1230
1231
		if ( $dbw->affectedRows() > 0 ) {
1232
			$newid = $pageId ?: $dbw->insertId();
1233
			$this->mId = $newid;
1234
			$this->mTitle->resetArticleID( $newid );
1235
1236
			return $newid;
1237
		} else {
1238
			return false; // nothing changed
1239
		}
1240
	}
1241
1242
	/**
1243
	 * Update the page record to point to a newly saved revision.
1244
	 *
1245
	 * @param IDatabase $dbw
1246
	 * @param Revision $revision For ID number, and text used to set
1247
	 *   length and redirect status fields
1248
	 * @param int $lastRevision If given, will not overwrite the page field
1249
	 *   when different from the currently set value.
1250
	 *   Giving 0 indicates the new page flag should be set on.
1251
	 * @param bool $lastRevIsRedirect If given, will optimize adding and
1252
	 *   removing rows in redirect table.
1253
	 * @return bool Success; false if the page row was missing or page_latest changed
1254
	 */
1255
	public function updateRevisionOn( $dbw, $revision, $lastRevision = null,
1256
		$lastRevIsRedirect = null
1257
	) {
1258
		global $wgContentHandlerUseDB;
1259
1260
		// Assertion to try to catch T92046
1261
		if ( (int)$revision->getId() === 0 ) {
1262
			throw new InvalidArgumentException(
1263
				__METHOD__ . ': Revision has ID ' . var_export( $revision->getId(), 1 )
1264
			);
1265
		}
1266
1267
		$content = $revision->getContent();
1268
		$len = $content ? $content->getSize() : 0;
1269
		$rt = $content ? $content->getUltimateRedirectTarget() : null;
1270
1271
		$conditions = [ 'page_id' => $this->getId() ];
1272
1273
		if ( !is_null( $lastRevision ) ) {
1274
			// An extra check against threads stepping on each other
1275
			$conditions['page_latest'] = $lastRevision;
1276
		}
1277
1278
		$row = [ /* SET */
1279
			'page_latest'      => $revision->getId(),
1280
			'page_touched'     => $dbw->timestamp( $revision->getTimestamp() ),
1281
			'page_is_new'      => ( $lastRevision === 0 ) ? 1 : 0,
1282
			'page_is_redirect' => $rt !== null ? 1 : 0,
1283
			'page_len'         => $len,
1284
		];
1285
1286
		if ( $wgContentHandlerUseDB ) {
1287
			$row['page_content_model'] = $revision->getContentModel();
1288
		}
1289
1290
		$dbw->update( 'page',
1291
			$row,
1292
			$conditions,
1293
			__METHOD__ );
1294
1295
		$result = $dbw->affectedRows() > 0;
1296
		if ( $result ) {
1297
			$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 1269 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...
1298
			$this->setLastEdit( $revision );
1299
			$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...
1300
			$this->mIsRedirect = (bool)$rt;
1301
			// Update the LinkCache.
1302
			LinkCache::singleton()->addGoodLinkObj(
1303
				$this->getId(),
1304
				$this->mTitle,
1305
				$len,
1306
				$this->mIsRedirect,
1307
				$this->mLatest,
1308
				$revision->getContentModel()
1309
			);
1310
		}
1311
1312
		return $result;
1313
	}
1314
1315
	/**
1316
	 * Add row to the redirect table if this is a redirect, remove otherwise.
1317
	 *
1318
	 * @param IDatabase $dbw
1319
	 * @param Title $redirectTitle Title object pointing to the redirect target,
1320
	 *   or NULL if this is not a redirect
1321
	 * @param null|bool $lastRevIsRedirect If given, will optimize adding and
1322
	 *   removing rows in redirect table.
1323
	 * @return bool True on success, false on failure
1324
	 * @private
1325
	 */
1326
	public function updateRedirectOn( $dbw, $redirectTitle, $lastRevIsRedirect = null ) {
1327
		// Always update redirects (target link might have changed)
1328
		// Update/Insert if we don't know if the last revision was a redirect or not
1329
		// Delete if changing from redirect to non-redirect
1330
		$isRedirect = !is_null( $redirectTitle );
1331
1332
		if ( !$isRedirect && $lastRevIsRedirect === false ) {
1333
			return true;
1334
		}
1335
1336
		if ( $isRedirect ) {
1337
			$this->insertRedirectEntry( $redirectTitle );
1338
		} else {
1339
			// This is not a redirect, remove row from redirect table
1340
			$where = [ 'rd_from' => $this->getId() ];
1341
			$dbw->delete( 'redirect', $where, __METHOD__ );
1342
		}
1343
1344
		if ( $this->getTitle()->getNamespace() == NS_FILE ) {
1345
			RepoGroup::singleton()->getLocalRepo()->invalidateImageRedirect( $this->getTitle() );
1346
		}
1347
1348
		return ( $dbw->affectedRows() != 0 );
1349
	}
1350
1351
	/**
1352
	 * If the given revision is newer than the currently set page_latest,
1353
	 * update the page record. Otherwise, do nothing.
1354
	 *
1355
	 * @deprecated since 1.24, use updateRevisionOn instead
1356
	 *
1357
	 * @param IDatabase $dbw
1358
	 * @param Revision $revision
1359
	 * @return bool
1360
	 */
1361
	public function updateIfNewerOn( $dbw, $revision ) {
1362
1363
		$row = $dbw->selectRow(
1364
			[ 'revision', 'page' ],
1365
			[ 'rev_id', 'rev_timestamp', 'page_is_redirect' ],
1366
			[
1367
				'page_id' => $this->getId(),
1368
				'page_latest=rev_id' ],
1369
			__METHOD__ );
1370
1371
		if ( $row ) {
1372
			if ( wfTimestamp( TS_MW, $row->rev_timestamp ) >= $revision->getTimestamp() ) {
1373
				return false;
1374
			}
1375
			$prev = $row->rev_id;
1376
			$lastRevIsRedirect = (bool)$row->page_is_redirect;
1377
		} else {
1378
			// No or missing previous revision; mark the page as new
1379
			$prev = 0;
1380
			$lastRevIsRedirect = null;
1381
		}
1382
1383
		$ret = $this->updateRevisionOn( $dbw, $revision, $prev, $lastRevIsRedirect );
1384
1385
		return $ret;
1386
	}
1387
1388
	/**
1389
	 * Get the content that needs to be saved in order to undo all revisions
1390
	 * between $undo and $undoafter. Revisions must belong to the same page,
1391
	 * must exist and must not be deleted
1392
	 * @param Revision $undo
1393
	 * @param Revision $undoafter Must be an earlier revision than $undo
1394
	 * @return Content|bool Content on success, false on failure
1395
	 * @since 1.21
1396
	 * Before we had the Content object, this was done in getUndoText
1397
	 */
1398
	public function getUndoContent( Revision $undo, Revision $undoafter = null ) {
1399
		$handler = $undo->getContentHandler();
1400
		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 1398 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...
1401
	}
1402
1403
	/**
1404
	 * Returns true if this page's content model supports sections.
1405
	 *
1406
	 * @return bool
1407
	 *
1408
	 * @todo The skin should check this and not offer section functionality if
1409
	 *   sections are not supported.
1410
	 * @todo The EditPage should check this and not offer section functionality
1411
	 *   if sections are not supported.
1412
	 */
1413
	public function supportsSections() {
1414
		return $this->getContentHandler()->supportsSections();
1415
	}
1416
1417
	/**
1418
	 * @param string|number|null|bool $sectionId Section identifier as a number or string
1419
	 * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page
1420
	 * or 'new' for a new section.
1421
	 * @param Content $sectionContent New content of the section.
1422
	 * @param string $sectionTitle New section's subject, only if $section is "new".
1423
	 * @param string $edittime Revision timestamp or null to use the current revision.
1424
	 *
1425
	 * @throws MWException
1426
	 * @return Content|null New complete article content, or null if error.
1427
	 *
1428
	 * @since 1.21
1429
	 * @deprecated since 1.24, use replaceSectionAtRev instead
1430
	 */
1431
	public function replaceSectionContent(
1432
		$sectionId, Content $sectionContent, $sectionTitle = '', $edittime = null
1433
	) {
1434
1435
		$baseRevId = null;
1436
		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...
1437
			$dbr = wfGetDB( DB_REPLICA );
1438
			$rev = Revision::loadFromTimestamp( $dbr, $this->mTitle, $edittime );
1439
			// Try the master if this thread may have just added it.
1440
			// This could be abstracted into a Revision method, but we don't want
1441
			// to encourage loading of revisions by timestamp.
1442
			if ( !$rev
1443
				&& 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...
1444
				&& 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...
1445
			) {
1446
				$dbw = wfGetDB( DB_MASTER );
1447
				$rev = Revision::loadFromTimestamp( $dbw, $this->mTitle, $edittime );
1448
			}
1449
			if ( $rev ) {
1450
				$baseRevId = $rev->getId();
1451
			}
1452
		}
1453
1454
		return $this->replaceSectionAtRev( $sectionId, $sectionContent, $sectionTitle, $baseRevId );
1455
	}
1456
1457
	/**
1458
	 * @param string|number|null|bool $sectionId Section identifier as a number or string
1459
	 * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page
1460
	 * or 'new' for a new section.
1461
	 * @param Content $sectionContent New content of the section.
1462
	 * @param string $sectionTitle New section's subject, only if $section is "new".
1463
	 * @param int|null $baseRevId
1464
	 *
1465
	 * @throws MWException
1466
	 * @return Content|null New complete article content, or null if error.
1467
	 *
1468
	 * @since 1.24
1469
	 */
1470
	public function replaceSectionAtRev( $sectionId, Content $sectionContent,
1471
		$sectionTitle = '', $baseRevId = null
1472
	) {
1473
1474
		if ( strval( $sectionId ) === '' ) {
1475
			// Whole-page edit; let the whole text through
1476
			$newContent = $sectionContent;
1477
		} else {
1478
			if ( !$this->supportsSections() ) {
1479
				throw new MWException( "sections not supported for content model " .
1480
					$this->getContentHandler()->getModelID() );
1481
			}
1482
1483
			// Bug 30711: always use current version when adding a new section
1484
			if ( is_null( $baseRevId ) || $sectionId === 'new' ) {
1485
				$oldContent = $this->getContent();
1486
			} else {
1487
				$rev = Revision::newFromId( $baseRevId );
1488
				if ( !$rev ) {
1489
					wfDebug( __METHOD__ . " asked for bogus section (page: " .
1490
						$this->getId() . "; section: $sectionId)\n" );
1491
					return null;
1492
				}
1493
1494
				$oldContent = $rev->getContent();
1495
			}
1496
1497
			if ( !$oldContent ) {
1498
				wfDebug( __METHOD__ . ": no page text\n" );
1499
				return null;
1500
			}
1501
1502
			$newContent = $oldContent->replaceSection( $sectionId, $sectionContent, $sectionTitle );
1503
		}
1504
1505
		return $newContent;
1506
	}
1507
1508
	/**
1509
	 * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
1510
	 * @param int $flags
1511
	 * @return int Updated $flags
1512
	 */
1513
	public function checkFlags( $flags ) {
1514
		if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) {
1515
			if ( $this->exists() ) {
1516
				$flags |= EDIT_UPDATE;
1517
			} else {
1518
				$flags |= EDIT_NEW;
1519
			}
1520
		}
1521
1522
		return $flags;
1523
	}
1524
1525
	/**
1526
	 * Change an existing article or create a new article. Updates RC and all necessary caches,
1527
	 * optionally via the deferred update array.
1528
	 *
1529
	 * @param string $text New text
1530
	 * @param string $summary Edit summary
1531
	 * @param int $flags Bitfield:
1532
	 *      EDIT_NEW
1533
	 *          Article is known or assumed to be non-existent, create a new one
1534
	 *      EDIT_UPDATE
1535
	 *          Article is known or assumed to be pre-existing, update it
1536
	 *      EDIT_MINOR
1537
	 *          Mark this edit minor, if the user is allowed to do so
1538
	 *      EDIT_SUPPRESS_RC
1539
	 *          Do not log the change in recentchanges
1540
	 *      EDIT_FORCE_BOT
1541
	 *          Mark the edit a "bot" edit regardless of user rights
1542
	 *      EDIT_AUTOSUMMARY
1543
	 *          Fill in blank summaries with generated text where possible
1544
	 *      EDIT_INTERNAL
1545
	 *          Signal that the page retrieve/save cycle happened entirely in this request.
1546
	 *
1547
	 * If neither EDIT_NEW nor EDIT_UPDATE is specified, the status of the
1548
	 * article will be detected. If EDIT_UPDATE is specified and the article
1549
	 * doesn't exist, the function will return an edit-gone-missing error. If
1550
	 * EDIT_NEW is specified and the article does exist, an edit-already-exists
1551
	 * error will be returned. These two conditions are also possible with
1552
	 * auto-detection due to MediaWiki's performance-optimised locking strategy.
1553
	 *
1554
	 * @param bool|int $baseRevId The revision ID this edit was based off, if any.
1555
	 *   This is not the parent revision ID, rather the revision ID for older
1556
	 *   content used as the source for a rollback, for example.
1557
	 * @param User $user The user doing the edit
1558
	 *
1559
	 * @throws MWException
1560
	 * @return Status Possible errors:
1561
	 *   edit-hook-aborted: The ArticleSave hook aborted the edit but didn't
1562
	 *     set the fatal flag of $status
1563
	 *   edit-gone-missing: In update mode, but the article didn't exist.
1564
	 *   edit-conflict: In update mode, the article changed unexpectedly.
1565
	 *   edit-no-change: Warning that the text was the same as before.
1566
	 *   edit-already-exists: In creation mode, but the article already exists.
1567
	 *
1568
	 * Extensions may define additional errors.
1569
	 *
1570
	 * $return->value will contain an associative array with members as follows:
1571
	 *     new: Boolean indicating if the function attempted to create a new article.
1572
	 *     revision: The revision object for the inserted revision, or null.
1573
	 *
1574
	 * Compatibility note: this function previously returned a boolean value
1575
	 * indicating success/failure
1576
	 *
1577
	 * @deprecated since 1.21: use doEditContent() instead.
1578
	 */
1579
	public function doEdit( $text, $summary, $flags = 0, $baseRevId = false, $user = null ) {
1580
		ContentHandler::deprecated( __METHOD__, '1.21' );
1581
1582
		$content = ContentHandler::makeContent( $text, $this->getTitle() );
1583
1584
		return $this->doEditContent( $content, $summary, $flags, $baseRevId, $user );
1585
	}
1586
1587
	/**
1588
	 * Change an existing article or create a new article. Updates RC and all necessary caches,
1589
	 * optionally via the deferred update array.
1590
	 *
1591
	 * @param Content $content New content
1592
	 * @param string $summary Edit summary
1593
	 * @param int $flags Bitfield:
1594
	 *      EDIT_NEW
1595
	 *          Article is known or assumed to be non-existent, create a new one
1596
	 *      EDIT_UPDATE
1597
	 *          Article is known or assumed to be pre-existing, update it
1598
	 *      EDIT_MINOR
1599
	 *          Mark this edit minor, if the user is allowed to do so
1600
	 *      EDIT_SUPPRESS_RC
1601
	 *          Do not log the change in recentchanges
1602
	 *      EDIT_FORCE_BOT
1603
	 *          Mark the edit a "bot" edit regardless of user rights
1604
	 *      EDIT_AUTOSUMMARY
1605
	 *          Fill in blank summaries with generated text where possible
1606
	 *      EDIT_INTERNAL
1607
	 *          Signal that the page retrieve/save cycle happened entirely in this request.
1608
	 *
1609
	 * If neither EDIT_NEW nor EDIT_UPDATE is specified, the status of the
1610
	 * article will be detected. If EDIT_UPDATE is specified and the article
1611
	 * doesn't exist, the function will return an edit-gone-missing error. If
1612
	 * EDIT_NEW is specified and the article does exist, an edit-already-exists
1613
	 * error will be returned. These two conditions are also possible with
1614
	 * auto-detection due to MediaWiki's performance-optimised locking strategy.
1615
	 *
1616
	 * @param bool|int $baseRevId The revision ID this edit was based off, if any.
1617
	 *   This is not the parent revision ID, rather the revision ID for older
1618
	 *   content used as the source for a rollback, for example.
1619
	 * @param User $user The user doing the edit
1620
	 * @param string $serialFormat Format for storing the content in the
1621
	 *   database.
1622
	 * @param array|null $tags Change tags to apply to this edit
1623
	 * Callers are responsible for permission checks
1624
	 * (with ChangeTags::canAddTagsAccompanyingChange)
1625
	 *
1626
	 * @throws MWException
1627
	 * @return Status Possible errors:
1628
	 *     edit-hook-aborted: The ArticleSave hook aborted the edit but didn't
1629
	 *       set the fatal flag of $status.
1630
	 *     edit-gone-missing: In update mode, but the article didn't exist.
1631
	 *     edit-conflict: In update mode, the article changed unexpectedly.
1632
	 *     edit-no-change: Warning that the text was the same as before.
1633
	 *     edit-already-exists: In creation mode, but the article already exists.
1634
	 *
1635
	 *  Extensions may define additional errors.
1636
	 *
1637
	 *  $return->value will contain an associative array with members as follows:
1638
	 *     new: Boolean indicating if the function attempted to create a new article.
1639
	 *     revision: The revision object for the inserted revision, or null.
1640
	 *
1641
	 * @since 1.21
1642
	 * @throws MWException
1643
	 */
1644
	public function doEditContent(
1645
		Content $content, $summary, $flags = 0, $baseRevId = false,
1646
		User $user = null, $serialFormat = null, $tags = []
1647
	) {
1648
		global $wgUser, $wgUseAutomaticEditSummaries;
1649
1650
		// Old default parameter for $tags was null
1651
		if ( $tags === null ) {
1652
			$tags = [];
1653
		}
1654
1655
		// Low-level sanity check
1656
		if ( $this->mTitle->getText() === '' ) {
1657
			throw new MWException( 'Something is trying to edit an article with an empty title' );
1658
		}
1659
		// Make sure the given content type is allowed for this page
1660
		if ( !$content->getContentHandler()->canBeUsedOn( $this->mTitle ) ) {
1661
			return Status::newFatal( 'content-not-allowed-here',
1662
				ContentHandler::getLocalizedName( $content->getModel() ),
1663
				$this->mTitle->getPrefixedText()
1664
			);
1665
		}
1666
1667
		// Load the data from the master database if needed.
1668
		// The caller may already loaded it from the master or even loaded it using
1669
		// SELECT FOR UPDATE, so do not override that using clear().
1670
		$this->loadPageData( 'fromdbmaster' );
1671
1672
		$user = $user ?: $wgUser;
1673
		$flags = $this->checkFlags( $flags );
1674
1675
		// Trigger pre-save hook (using provided edit summary)
1676
		$hookStatus = Status::newGood( [] );
1677
		$hook_args = [ &$this, &$user, &$content, &$summary,
1678
							$flags & EDIT_MINOR, null, null, &$flags, &$hookStatus ];
1679
		// Check if the hook rejected the attempted save
1680
		if ( !Hooks::run( 'PageContentSave', $hook_args )
1681
			|| !ContentHandler::runLegacyHooks( 'ArticleSave', $hook_args )
1682
		) {
1683
			if ( $hookStatus->isOK() ) {
1684
				// Hook returned false but didn't call fatal(); use generic message
1685
				$hookStatus->fatal( 'edit-hook-aborted' );
1686
			}
1687
1688
			return $hookStatus;
1689
		}
1690
1691
		$old_revision = $this->getRevision(); // current revision
1692
		$old_content = $this->getContent( Revision::RAW ); // current revision's content
1693
1694
		if ( $old_content && $old_content->getModel() !== $content->getModel() ) {
1695
			$tags[] = 'mw-contentmodelchange';
1696
		}
1697
1698
		// Provide autosummaries if one is not provided and autosummaries are enabled
1699
		if ( $wgUseAutomaticEditSummaries && ( $flags & EDIT_AUTOSUMMARY ) && $summary == '' ) {
1700
			$handler = $content->getContentHandler();
1701
			$summary = $handler->getAutosummary( $old_content, $content, $flags );
1702
		}
1703
1704
		// Avoid statsd noise and wasted cycles check the edit stash (T136678)
1705
		if ( ( $flags & EDIT_INTERNAL ) || ( $flags & EDIT_FORCE_BOT ) ) {
1706
			$useCache = false;
1707
		} else {
1708
			$useCache = true;
1709
		}
1710
1711
		// Get the pre-save transform content and final parser output
1712
		$editInfo = $this->prepareContentForEdit( $content, null, $user, $serialFormat, $useCache );
1713
		$pstContent = $editInfo->pstContent; // Content object
1714
		$meta = [
1715
			'bot' => ( $flags & EDIT_FORCE_BOT ),
1716
			'minor' => ( $flags & EDIT_MINOR ) && $user->isAllowed( 'minoredit' ),
1717
			'serialized' => $editInfo->pst,
1718
			'serialFormat' => $serialFormat,
1719
			'baseRevId' => $baseRevId,
1720
			'oldRevision' => $old_revision,
1721
			'oldContent' => $old_content,
1722
			'oldId' => $this->getLatest(),
1723
			'oldIsRedirect' => $this->isRedirect(),
1724
			'oldCountable' => $this->isCountable(),
1725
			'tags' => ( $tags !== null ) ? (array)$tags : []
1726
		];
1727
1728
		// Actually create the revision and create/update the page
1729
		if ( $flags & EDIT_UPDATE ) {
1730
			$status = $this->doModify( $pstContent, $flags, $user, $summary, $meta );
1731
		} else {
1732
			$status = $this->doCreate( $pstContent, $flags, $user, $summary, $meta );
1733
		}
1734
1735
		// Promote user to any groups they meet the criteria for
1736
		DeferredUpdates::addCallableUpdate( function () use ( $user ) {
1737
			$user->addAutopromoteOnceGroups( 'onEdit' );
1738
			$user->addAutopromoteOnceGroups( 'onView' ); // b/c
1739
		} );
1740
1741
		return $status;
1742
	}
1743
1744
	/**
1745
	 * @param Content $content Pre-save transform content
1746
	 * @param integer $flags
1747
	 * @param User $user
1748
	 * @param string $summary
1749
	 * @param array $meta
1750
	 * @return Status
1751
	 * @throws DBUnexpectedError
1752
	 * @throws Exception
1753
	 * @throws FatalError
1754
	 * @throws MWException
1755
	 */
1756
	private function doModify(
1757
		Content $content, $flags, User $user, $summary, array $meta
1758
	) {
1759
		global $wgUseRCPatrol;
1760
1761
		// Update article, but only if changed.
1762
		$status = Status::newGood( [ 'new' => false, 'revision' => null ] );
1763
1764
		// Convenience variables
1765
		$now = wfTimestampNow();
1766
		$oldid = $meta['oldId'];
1767
		/** @var $oldContent Content|null */
1768
		$oldContent = $meta['oldContent'];
1769
		$newsize = $content->getSize();
1770
1771
		if ( !$oldid ) {
1772
			// Article gone missing
1773
			$status->fatal( 'edit-gone-missing' );
1774
1775
			return $status;
1776
		} elseif ( !$oldContent ) {
1777
			// Sanity check for bug 37225
1778
			throw new MWException( "Could not find text for current revision {$oldid}." );
1779
		}
1780
1781
		// @TODO: pass content object?!
1782
		$revision = new Revision( [
1783
			'page'       => $this->getId(),
1784
			'title'      => $this->mTitle, // for determining the default content model
1785
			'comment'    => $summary,
1786
			'minor_edit' => $meta['minor'],
1787
			'text'       => $meta['serialized'],
1788
			'len'        => $newsize,
1789
			'parent_id'  => $oldid,
1790
			'user'       => $user->getId(),
1791
			'user_text'  => $user->getName(),
1792
			'timestamp'  => $now,
1793
			'content_model' => $content->getModel(),
1794
			'content_format' => $meta['serialFormat'],
1795
		] );
1796
1797
		$changed = !$content->equals( $oldContent );
1798
1799
		$dbw = wfGetDB( DB_MASTER );
1800
1801
		if ( $changed ) {
1802
			$prepStatus = $content->prepareSave( $this, $flags, $oldid, $user );
1803
			$status->merge( $prepStatus );
1804
			if ( !$status->isOK() ) {
1805
				return $status;
1806
			}
1807
1808
			$dbw->startAtomic( __METHOD__ );
1809
			// Get the latest page_latest value while locking it.
1810
			// Do a CAS style check to see if it's the same as when this method
1811
			// started. If it changed then bail out before touching the DB.
1812
			$latestNow = $this->lockAndGetLatest();
1813
			if ( $latestNow != $oldid ) {
1814
				$dbw->endAtomic( __METHOD__ );
1815
				// Page updated or deleted in the mean time
1816
				$status->fatal( 'edit-conflict' );
1817
1818
				return $status;
1819
			}
1820
1821
			// At this point we are now comitted to returning an OK
1822
			// status unless some DB query error or other exception comes up.
1823
			// This way callers don't have to call rollback() if $status is bad
1824
			// unless they actually try to catch exceptions (which is rare).
1825
1826
			// Save the revision text
1827
			$revisionId = $revision->insertOn( $dbw );
1828
			// Update page_latest and friends to reflect the new revision
1829
			if ( !$this->updateRevisionOn( $dbw, $revision, null, $meta['oldIsRedirect'] ) ) {
1830
				throw new MWException( "Failed to update page row to use new revision." );
1831
			}
1832
1833
			Hooks::run( 'NewRevisionFromEditComplete',
1834
				[ $this, $revision, $meta['baseRevId'], $user ] );
1835
1836
			// Update recentchanges
1837
			if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
1838
				// Mark as patrolled if the user can do so
1839
				$patrolled = $wgUseRCPatrol && !count(
1840
						$this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) );
1841
				// Add RC row to the DB
1842
				RecentChange::notifyEdit(
1843
					$now,
0 ignored issues
show
Security Bug introduced by
It seems like $now defined by wfTimestampNow() on line 1765 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...
1844
					$this->mTitle,
1845
					$revision->isMinor(),
1846
					$user,
1847
					$summary,
1848
					$oldid,
1849
					$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...
1850
					$meta['bot'],
1851
					'',
1852
					$oldContent ? $oldContent->getSize() : 0,
1853
					$newsize,
1854
					$revisionId,
1855
					$patrolled,
1856
					$meta['tags']
1857
				);
1858
			}
1859
1860
			$user->incEditCount();
1861
1862
			$dbw->endAtomic( __METHOD__ );
1863
			$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...
1864
		} else {
1865
			// Bug 32948: revision ID must be set to page {{REVISIONID}} and
1866
			// related variables correctly. Likewise for {{REVISIONUSER}} (T135261).
1867
			$revision->setId( $this->getLatest() );
1868
			$revision->setUserIdAndName(
1869
				$this->getUser( Revision::RAW ),
1870
				$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...
1871
			);
1872
		}
1873
1874
		if ( $changed ) {
1875
			// Return the new revision to the caller
1876
			$status->value['revision'] = $revision;
1877
		} else {
1878
			$status->warning( 'edit-no-change' );
1879
			// Update page_touched as updateRevisionOn() was not called.
1880
			// Other cache updates are managed in onArticleEdit() via doEditUpdates().
1881
			$this->mTitle->invalidateCache( $now );
0 ignored issues
show
Security Bug introduced by
It seems like $now defined by wfTimestampNow() on line 1765 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...
1882
		}
1883
1884
		// Do secondary updates once the main changes have been committed...
1885
		DeferredUpdates::addUpdate(
1886
			new AtomicSectionUpdate(
1887
				$dbw,
1888
				__METHOD__,
1889
				function () use (
1890
					$revision, &$user, $content, $summary, &$flags,
1891
					$changed, $meta, &$status
1892
				) {
1893
					// Update links tables, site stats, etc.
1894
					$this->doEditUpdates(
1895
						$revision,
1896
						$user,
1897
						[
1898
							'changed' => $changed,
1899
							'oldcountable' => $meta['oldCountable'],
1900
							'oldrevision' => $meta['oldRevision']
1901
						]
1902
					);
1903
					// Trigger post-save hook
1904
					$params = [ &$this, &$user, $content, $summary, $flags & EDIT_MINOR,
1905
						null, null, &$flags, $revision, &$status, $meta['baseRevId'] ];
1906
					ContentHandler::runLegacyHooks( 'ArticleSaveComplete', $params );
1907
					Hooks::run( 'PageContentSaveComplete', $params );
1908
				}
1909
			),
1910
			DeferredUpdates::PRESEND
1911
		);
1912
1913
		return $status;
1914
	}
1915
1916
	/**
1917
	 * @param Content $content Pre-save transform content
1918
	 * @param integer $flags
1919
	 * @param User $user
1920
	 * @param string $summary
1921
	 * @param array $meta
1922
	 * @return Status
1923
	 * @throws DBUnexpectedError
1924
	 * @throws Exception
1925
	 * @throws FatalError
1926
	 * @throws MWException
1927
	 */
1928
	private function doCreate(
1929
		Content $content, $flags, User $user, $summary, array $meta
1930
	) {
1931
		global $wgUseRCPatrol, $wgUseNPPatrol;
1932
1933
		$status = Status::newGood( [ 'new' => true, 'revision' => null ] );
1934
1935
		$now = wfTimestampNow();
1936
		$newsize = $content->getSize();
1937
		$prepStatus = $content->prepareSave( $this, $flags, $meta['oldId'], $user );
1938
		$status->merge( $prepStatus );
1939
		if ( !$status->isOK() ) {
1940
			return $status;
1941
		}
1942
1943
		$dbw = wfGetDB( DB_MASTER );
1944
		$dbw->startAtomic( __METHOD__ );
1945
1946
		// Add the page record unless one already exists for the title
1947
		$newid = $this->insertOn( $dbw );
1948
		if ( $newid === false ) {
1949
			$dbw->endAtomic( __METHOD__ ); // nothing inserted
1950
			$status->fatal( 'edit-already-exists' );
1951
1952
			return $status; // nothing done
1953
		}
1954
1955
		// At this point we are now comitted to returning an OK
1956
		// status unless some DB query error or other exception comes up.
1957
		// This way callers don't have to call rollback() if $status is bad
1958
		// unless they actually try to catch exceptions (which is rare).
1959
1960
		// @TODO: pass content object?!
1961
		$revision = new Revision( [
1962
			'page'       => $newid,
1963
			'title'      => $this->mTitle, // for determining the default content model
1964
			'comment'    => $summary,
1965
			'minor_edit' => $meta['minor'],
1966
			'text'       => $meta['serialized'],
1967
			'len'        => $newsize,
1968
			'user'       => $user->getId(),
1969
			'user_text'  => $user->getName(),
1970
			'timestamp'  => $now,
1971
			'content_model' => $content->getModel(),
1972
			'content_format' => $meta['serialFormat'],
1973
		] );
1974
1975
		// Save the revision text...
1976
		$revisionId = $revision->insertOn( $dbw );
1977
		// Update the page record with revision data
1978
		if ( !$this->updateRevisionOn( $dbw, $revision, 0 ) ) {
1979
			throw new MWException( "Failed to update page row to use new revision." );
1980
		}
1981
1982
		Hooks::run( 'NewRevisionFromEditComplete', [ $this, $revision, false, $user ] );
1983
1984
		// Update recentchanges
1985
		if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
1986
			// Mark as patrolled if the user can do so
1987
			$patrolled = ( $wgUseRCPatrol || $wgUseNPPatrol ) &&
1988
				!count( $this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) );
1989
			// Add RC row to the DB
1990
			RecentChange::notifyNew(
1991
				$now,
0 ignored issues
show
Security Bug introduced by
It seems like $now defined by wfTimestampNow() on line 1935 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...
1992
				$this->mTitle,
1993
				$revision->isMinor(),
1994
				$user,
1995
				$summary,
1996
				$meta['bot'],
1997
				'',
1998
				$newsize,
1999
				$revisionId,
2000
				$patrolled,
2001
				$meta['tags']
2002
			);
2003
		}
2004
2005
		$user->incEditCount();
2006
2007
		$dbw->endAtomic( __METHOD__ );
2008
		$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...
2009
2010
		// Return the new revision to the caller
2011
		$status->value['revision'] = $revision;
2012
2013
		// Do secondary updates once the main changes have been committed...
2014
		DeferredUpdates::addUpdate(
2015
			new AtomicSectionUpdate(
2016
				$dbw,
2017
				__METHOD__,
2018
				function () use (
2019
					$revision, &$user, $content, $summary, &$flags, $meta, &$status
2020
				) {
2021
					// Update links, etc.
2022
					$this->doEditUpdates( $revision, $user, [ 'created' => true ] );
2023
					// Trigger post-create hook
2024
					$params = [ &$this, &$user, $content, $summary,
2025
						$flags & EDIT_MINOR, null, null, &$flags, $revision ];
2026
					ContentHandler::runLegacyHooks( 'ArticleInsertComplete', $params );
2027
					Hooks::run( 'PageContentInsertComplete', $params );
2028
					// Trigger post-save hook
2029
					$params = array_merge( $params, [ &$status, $meta['baseRevId'] ] );
2030
					ContentHandler::runLegacyHooks( 'ArticleSaveComplete', $params );
2031
					Hooks::run( 'PageContentSaveComplete', $params );
2032
2033
				}
2034
			),
2035
			DeferredUpdates::PRESEND
2036
		);
2037
2038
		return $status;
2039
	}
2040
2041
	/**
2042
	 * Get parser options suitable for rendering the primary article wikitext
2043
	 *
2044
	 * @see ContentHandler::makeParserOptions
2045
	 *
2046
	 * @param IContextSource|User|string $context One of the following:
2047
	 *        - IContextSource: Use the User and the Language of the provided
2048
	 *          context
2049
	 *        - User: Use the provided User object and $wgLang for the language,
2050
	 *          so use an IContextSource object if possible.
2051
	 *        - 'canonical': Canonical options (anonymous user with default
2052
	 *          preferences and content language).
2053
	 * @return ParserOptions
2054
	 */
2055
	public function makeParserOptions( $context ) {
2056
		$options = $this->getContentHandler()->makeParserOptions( $context );
2057
2058
		if ( $this->getTitle()->isConversionTable() ) {
2059
			// @todo ConversionTable should become a separate content model, so
2060
			// we don't need special cases like this one.
2061
			$options->disableContentConversion();
2062
		}
2063
2064
		return $options;
2065
	}
2066
2067
	/**
2068
	 * Prepare text which is about to be saved.
2069
	 * Returns a stdClass with source, pst and output members
2070
	 *
2071
	 * @param string $text
2072
	 * @param int|null $revid
2073
	 * @param User|null $user
2074
	 * @deprecated since 1.21: use prepareContentForEdit instead.
2075
	 * @return object
2076
	 */
2077
	public function prepareTextForEdit( $text, $revid = null, User $user = null ) {
2078
		ContentHandler::deprecated( __METHOD__, '1.21' );
2079
		$content = ContentHandler::makeContent( $text, $this->getTitle() );
2080
		return $this->prepareContentForEdit( $content, $revid, $user );
2081
	}
2082
2083
	/**
2084
	 * Prepare content which is about to be saved.
2085
	 * Returns a stdClass with source, pst and output members
2086
	 *
2087
	 * @param Content $content
2088
	 * @param Revision|int|null $revision Revision object. For backwards compatibility, a
2089
	 *        revision ID is also accepted, but this is deprecated.
2090
	 * @param User|null $user
2091
	 * @param string|null $serialFormat
2092
	 * @param bool $useCache Check shared prepared edit cache
2093
	 *
2094
	 * @return object
2095
	 *
2096
	 * @since 1.21
2097
	 */
2098
	public function prepareContentForEdit(
2099
		Content $content, $revision = null, User $user = null,
2100
		$serialFormat = null, $useCache = true
2101
	) {
2102
		global $wgContLang, $wgUser, $wgAjaxEditStash;
2103
2104
		if ( is_object( $revision ) ) {
2105
			$revid = $revision->getId();
2106
		} else {
2107
			$revid = $revision;
2108
			// This code path is deprecated, and nothing is known to
2109
			// use it, so performance here shouldn't be a worry.
2110
			if ( $revid !== null ) {
2111
				$revision = Revision::newFromId( $revid, Revision::READ_LATEST );
2112
			} else {
2113
				$revision = null;
2114
			}
2115
		}
2116
2117
		$user = is_null( $user ) ? $wgUser : $user;
2118
		// XXX: check $user->getId() here???
2119
2120
		// Use a sane default for $serialFormat, see bug 57026
2121
		if ( $serialFormat === null ) {
2122
			$serialFormat = $content->getContentHandler()->getDefaultFormat();
2123
		}
2124
2125
		if ( $this->mPreparedEdit
2126
			&& isset( $this->mPreparedEdit->newContent )
2127
			&& $this->mPreparedEdit->newContent->equals( $content )
2128
			&& $this->mPreparedEdit->revid == $revid
2129
			&& $this->mPreparedEdit->format == $serialFormat
2130
			// XXX: also check $user here?
2131
		) {
2132
			// Already prepared
2133
			return $this->mPreparedEdit;
2134
		}
2135
2136
		// The edit may have already been prepared via api.php?action=stashedit
2137
		$cachedEdit = $useCache && $wgAjaxEditStash
2138
			? ApiStashEdit::checkCache( $this->getTitle(), $content, $user )
2139
			: false;
2140
2141
		$popts = ParserOptions::newFromUserAndLang( $user, $wgContLang );
2142
		Hooks::run( 'ArticlePrepareTextForEdit', [ $this, $popts ] );
2143
2144
		$edit = (object)[];
2145
		if ( $cachedEdit ) {
2146
			$edit->timestamp = $cachedEdit->timestamp;
2147
		} else {
2148
			$edit->timestamp = wfTimestampNow();
2149
		}
2150
		// @note: $cachedEdit is safely not used if the rev ID was referenced in the text
2151
		$edit->revid = $revid;
2152
2153
		if ( $cachedEdit ) {
2154
			$edit->pstContent = $cachedEdit->pstContent;
2155
		} else {
2156
			$edit->pstContent = $content
2157
				? $content->preSaveTransform( $this->mTitle, $user, $popts )
2158
				: null;
2159
		}
2160
2161
		$edit->format = $serialFormat;
2162
		$edit->popts = $this->makeParserOptions( 'canonical' );
2163
		if ( $cachedEdit ) {
2164
			$edit->output = $cachedEdit->output;
2165
		} else {
2166
			if ( $revision ) {
2167
				// We get here if vary-revision is set. This means that this page references
2168
				// itself (such as via self-transclusion). In this case, we need to make sure
2169
				// that any such self-references refer to the newly-saved revision, and not
2170
				// to the previous one, which could otherwise happen due to replica DB lag.
2171
				$oldCallback = $edit->popts->getCurrentRevisionCallback();
2172
				$edit->popts->setCurrentRevisionCallback(
2173
					function ( Title $title, $parser = false ) use ( $revision, &$oldCallback ) {
2174
						if ( $title->equals( $revision->getTitle() ) ) {
2175
							return $revision;
2176
						} else {
2177
							return call_user_func( $oldCallback, $title, $parser );
2178
						}
2179
					}
2180
				);
2181
			} else {
2182
				// Try to avoid a second parse if {{REVISIONID}} is used
2183
				$edit->popts->setSpeculativeRevIdCallback( function () {
2184
					return 1 + (int)wfGetDB( DB_MASTER )->selectField(
2185
						'revision',
2186
						'MAX(rev_id)',
2187
						[],
2188
						__METHOD__
2189
					);
2190
				} );
2191
			}
2192
			$edit->output = $edit->pstContent
2193
				? $edit->pstContent->getParserOutput( $this->mTitle, $revid, $edit->popts )
2194
				: null;
2195
		}
2196
2197
		$edit->newContent = $content;
2198
		$edit->oldContent = $this->getContent( Revision::RAW );
2199
2200
		// NOTE: B/C for hooks! don't use these fields!
2201
		$edit->newText = $edit->newContent
2202
			? ContentHandler::getContentText( $edit->newContent )
2203
			: '';
2204
		$edit->oldText = $edit->oldContent
2205
			? ContentHandler::getContentText( $edit->oldContent )
2206
			: '';
2207
		$edit->pst = $edit->pstContent ? $edit->pstContent->serialize( $serialFormat ) : '';
2208
2209
		if ( $edit->output ) {
2210
			$edit->output->setCacheTime( wfTimestampNow() );
2211
		}
2212
2213
		// Process cache the result
2214
		$this->mPreparedEdit = $edit;
2215
2216
		return $edit;
2217
	}
2218
2219
	/**
2220
	 * Do standard deferred updates after page edit.
2221
	 * Update links tables, site stats, search index and message cache.
2222
	 * Purges pages that include this page if the text was changed here.
2223
	 * Every 100th edit, prune the recent changes table.
2224
	 *
2225
	 * @param Revision $revision
2226
	 * @param User $user User object that did the revision
2227
	 * @param array $options Array of options, following indexes are used:
2228
	 * - changed: boolean, whether the revision changed the content (default true)
2229
	 * - created: boolean, whether the revision created the page (default false)
2230
	 * - moved: boolean, whether the page was moved (default false)
2231
	 * - restored: boolean, whether the page was undeleted (default false)
2232
	 * - oldrevision: Revision object for the pre-update revision (default null)
2233
	 * - oldcountable: boolean, null, or string 'no-change' (default null):
2234
	 *   - boolean: whether the page was counted as an article before that
2235
	 *     revision, only used in changed is true and created is false
2236
	 *   - null: if created is false, don't update the article count; if created
2237
	 *     is true, do update the article count
2238
	 *   - 'no-change': don't update the article count, ever
2239
	 */
2240
	public function doEditUpdates( Revision $revision, User $user, array $options = [] ) {
2241
		global $wgRCWatchCategoryMembership, $wgContLang;
2242
2243
		$options += [
2244
			'changed' => true,
2245
			'created' => false,
2246
			'moved' => false,
2247
			'restored' => false,
2248
			'oldrevision' => null,
2249
			'oldcountable' => null
2250
		];
2251
		$content = $revision->getContent();
2252
2253
		$logger = LoggerFactory::getInstance( 'SaveParse' );
2254
2255
		// See if the parser output before $revision was inserted is still valid
2256
		$editInfo = false;
2257
		if ( !$this->mPreparedEdit ) {
2258
			$logger->debug( __METHOD__ . ": No prepared edit...\n" );
2259
		} elseif ( $this->mPreparedEdit->output->getFlag( 'vary-revision' ) ) {
2260
			$logger->info( __METHOD__ . ": Prepared edit has vary-revision...\n" );
2261
		} elseif ( $this->mPreparedEdit->output->getFlag( 'vary-revision-id' )
2262
			&& $this->mPreparedEdit->output->getSpeculativeRevIdUsed() !== $revision->getId()
2263
		) {
2264
			$logger->info( __METHOD__ . ": Prepared edit has vary-revision-id with wrong ID...\n" );
2265
		} elseif ( $this->mPreparedEdit->output->getFlag( 'vary-user' ) && !$options['changed'] ) {
2266
			$logger->info( __METHOD__ . ": Prepared edit has vary-user and is null...\n" );
2267
		} else {
2268
			wfDebug( __METHOD__ . ": Using prepared edit...\n" );
2269
			$editInfo = $this->mPreparedEdit;
2270
		}
2271
2272
		if ( !$editInfo ) {
2273
			// Parse the text again if needed. Be careful not to do pre-save transform twice:
2274
			// $text is usually already pre-save transformed once. Avoid using the edit stash
2275
			// as any prepared content from there or in doEditContent() was already rejected.
2276
			$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 2251 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...
2277
		}
2278
2279
		// Save it to the parser cache.
2280
		// Make sure the cache time matches page_touched to avoid double parsing.
2281
		ParserCache::singleton()->save(
2282
			$editInfo->output, $this, $editInfo->popts,
2283
			$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...
2284
		);
2285
2286
		// Update the links tables and other secondary data
2287
		if ( $content ) {
2288
			$recursive = $options['changed']; // bug 50785
2289
			$updates = $content->getSecondaryDataUpdates(
2290
				$this->getTitle(), null, $recursive, $editInfo->output
2291
			);
2292
			foreach ( $updates as $update ) {
2293
				if ( $update instanceof LinksUpdate ) {
2294
					$update->setRevision( $revision );
2295
					$update->setTriggeringUser( $user );
2296
				}
2297
				DeferredUpdates::addUpdate( $update );
2298
			}
2299
			if ( $wgRCWatchCategoryMembership
2300
				&& $this->getContentHandler()->supportsCategories() === true
2301
				&& ( $options['changed'] || $options['created'] )
2302
				&& !$options['restored']
2303
			) {
2304
				// Note: jobs are pushed after deferred updates, so the job should be able to see
2305
				// the recent change entry (also done via deferred updates) and carry over any
2306
				// bot/deletion/IP flags, ect.
2307
				JobQueueGroup::singleton()->lazyPush( new CategoryMembershipChangeJob(
2308
					$this->getTitle(),
2309
					[
2310
						'pageId' => $this->getId(),
2311
						'revTimestamp' => $revision->getTimestamp()
2312
					]
2313
				) );
2314
			}
2315
		}
2316
2317
		Hooks::run( 'ArticleEditUpdates', [ &$this, &$editInfo, $options['changed'] ] );
2318
2319
		if ( Hooks::run( 'ArticleEditUpdatesDeleteFromRecentchanges', [ &$this ] ) ) {
2320
			// Flush old entries from the `recentchanges` table
2321
			if ( mt_rand( 0, 9 ) == 0 ) {
2322
				JobQueueGroup::singleton()->lazyPush( RecentChangesUpdateJob::newPurgeJob() );
2323
			}
2324
		}
2325
2326
		if ( !$this->exists() ) {
2327
			return;
2328
		}
2329
2330
		$id = $this->getId();
2331
		$title = $this->mTitle->getPrefixedDBkey();
2332
		$shortTitle = $this->mTitle->getDBkey();
2333
2334
		if ( $options['oldcountable'] === 'no-change' ||
2335
			( !$options['changed'] && !$options['moved'] )
2336
		) {
2337
			$good = 0;
2338
		} elseif ( $options['created'] ) {
2339
			$good = (int)$this->isCountable( $editInfo );
2340
		} elseif ( $options['oldcountable'] !== null ) {
2341
			$good = (int)$this->isCountable( $editInfo ) - (int)$options['oldcountable'];
2342
		} else {
2343
			$good = 0;
2344
		}
2345
		$edits = $options['changed'] ? 1 : 0;
2346
		$total = $options['created'] ? 1 : 0;
2347
2348
		DeferredUpdates::addUpdate( new SiteStatsUpdate( 0, $edits, $good, $total ) );
2349
		DeferredUpdates::addUpdate( new SearchUpdate( $id, $title, $content ) );
0 ignored issues
show
Bug introduced by
It seems like $content defined by $revision->getContent() on line 2251 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...
2350
2351
		// If this is another user's talk page, update newtalk.
2352
		// Don't do this if $options['changed'] = false (null-edits) nor if
2353
		// it's a minor edit and the user doesn't want notifications for those.
2354
		if ( $options['changed']
2355
			&& $this->mTitle->getNamespace() == NS_USER_TALK
2356
			&& $shortTitle != $user->getTitleKey()
2357
			&& !( $revision->isMinor() && $user->isAllowed( 'nominornewtalk' ) )
2358
		) {
2359
			$recipient = User::newFromName( $shortTitle, false );
2360
			if ( !$recipient ) {
2361
				wfDebug( __METHOD__ . ": invalid username\n" );
2362
			} else {
2363
				// Allow extensions to prevent user notification
2364
				// when a new message is added to their talk page
2365
				if ( Hooks::run( 'ArticleEditUpdateNewTalk', [ &$this, $recipient ] ) ) {
2366
					if ( User::isIP( $shortTitle ) ) {
2367
						// An anonymous user
2368
						$recipient->setNewtalk( true, $revision );
2369
					} elseif ( $recipient->isLoggedIn() ) {
2370
						$recipient->setNewtalk( true, $revision );
2371
					} else {
2372
						wfDebug( __METHOD__ . ": don't need to notify a nonexistent user\n" );
2373
					}
2374
				}
2375
			}
2376
		}
2377
2378
		if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) {
2379
			// XXX: could skip pseudo-messages like js/css here, based on content model.
2380
			$msgtext = $content ? $content->getWikitextForTransclusion() : null;
2381
			if ( $msgtext === false || $msgtext === null ) {
2382
				$msgtext = '';
2383
			}
2384
2385
			MessageCache::singleton()->replace( $shortTitle, $msgtext );
2386
2387
			if ( $wgContLang->hasVariants() ) {
2388
				$wgContLang->updateConversionTable( $this->mTitle );
2389
			}
2390
		}
2391
2392
		if ( $options['created'] ) {
2393
			self::onArticleCreate( $this->mTitle );
2394
		} elseif ( $options['changed'] ) { // bug 50785
2395
			self::onArticleEdit( $this->mTitle, $revision );
2396
		}
2397
	}
2398
2399
	/**
2400
	 * Edit an article without doing all that other stuff
2401
	 * The article must already exist; link tables etc
2402
	 * are not updated, caches are not flushed.
2403
	 *
2404
	 * @param Content $content Content submitted
2405
	 * @param User $user The relevant user
2406
	 * @param string $comment Comment submitted
2407
	 * @param bool $minor Whereas it's a minor modification
2408
	 * @param string $serialFormat Format for storing the content in the database
2409
	 */
2410
	public function doQuickEditContent(
2411
		Content $content, User $user, $comment = '', $minor = false, $serialFormat = null
2412
	) {
2413
2414
		$serialized = $content->serialize( $serialFormat );
2415
2416
		$dbw = wfGetDB( DB_MASTER );
2417
		$revision = new Revision( [
2418
			'title'      => $this->getTitle(), // for determining the default content model
2419
			'page'       => $this->getId(),
2420
			'user_text'  => $user->getName(),
2421
			'user'       => $user->getId(),
2422
			'text'       => $serialized,
2423
			'length'     => $content->getSize(),
2424
			'comment'    => $comment,
2425
			'minor_edit' => $minor ? 1 : 0,
2426
		] ); // XXX: set the content object?
2427
		$revision->insertOn( $dbw );
2428
		$this->updateRevisionOn( $dbw, $revision );
2429
2430
		Hooks::run( 'NewRevisionFromEditComplete', [ $this, $revision, false, $user ] );
2431
2432
	}
2433
2434
	/**
2435
	 * Update the article's restriction field, and leave a log entry.
2436
	 * This works for protection both existing and non-existing pages.
2437
	 *
2438
	 * @param array $limit Set of restriction keys
2439
	 * @param array $expiry Per restriction type expiration
2440
	 * @param int &$cascade Set to false if cascading protection isn't allowed.
2441
	 * @param string $reason
2442
	 * @param User $user The user updating the restrictions
2443
	 * @param string|string[] $tags Change tags to add to the pages and protection log entries
2444
	 *   ($user should be able to add the specified tags before this is called)
2445
	 * @return Status Status object; if action is taken, $status->value is the log_id of the
2446
	 *   protection log entry.
2447
	 */
2448
	public function doUpdateRestrictions( array $limit, array $expiry,
2449
		&$cascade, $reason, User $user, $tags = null
2450
	) {
2451
		global $wgCascadingRestrictionLevels, $wgContLang;
2452
2453
		if ( wfReadOnly() ) {
2454
			return Status::newFatal( 'readonlytext', wfReadOnlyReason() );
2455
		}
2456
2457
		$this->loadPageData( 'fromdbmaster' );
2458
		$restrictionTypes = $this->mTitle->getRestrictionTypes();
2459
		$id = $this->getId();
2460
2461
		if ( !$cascade ) {
2462
			$cascade = false;
2463
		}
2464
2465
		// Take this opportunity to purge out expired restrictions
2466
		Title::purgeExpiredRestrictions();
2467
2468
		// @todo FIXME: Same limitations as described in ProtectionForm.php (line 37);
2469
		// we expect a single selection, but the schema allows otherwise.
2470
		$isProtected = false;
2471
		$protect = false;
2472
		$changed = false;
2473
2474
		$dbw = wfGetDB( DB_MASTER );
2475
2476
		foreach ( $restrictionTypes as $action ) {
2477
			if ( !isset( $expiry[$action] ) || $expiry[$action] === $dbw->getInfinity() ) {
2478
				$expiry[$action] = 'infinity';
2479
			}
2480
			if ( !isset( $limit[$action] ) ) {
2481
				$limit[$action] = '';
2482
			} elseif ( $limit[$action] != '' ) {
2483
				$protect = true;
2484
			}
2485
2486
			// Get current restrictions on $action
2487
			$current = implode( '', $this->mTitle->getRestrictions( $action ) );
2488
			if ( $current != '' ) {
2489
				$isProtected = true;
2490
			}
2491
2492
			if ( $limit[$action] != $current ) {
2493
				$changed = true;
2494
			} elseif ( $limit[$action] != '' ) {
2495
				// Only check expiry change if the action is actually being
2496
				// protected, since expiry does nothing on an not-protected
2497
				// action.
2498
				if ( $this->mTitle->getRestrictionExpiry( $action ) != $expiry[$action] ) {
2499
					$changed = true;
2500
				}
2501
			}
2502
		}
2503
2504
		if ( !$changed && $protect && $this->mTitle->areRestrictionsCascading() != $cascade ) {
2505
			$changed = true;
2506
		}
2507
2508
		// If nothing has changed, do nothing
2509
		if ( !$changed ) {
2510
			return Status::newGood();
2511
		}
2512
2513
		if ( !$protect ) { // No protection at all means unprotection
2514
			$revCommentMsg = 'unprotectedarticle';
2515
			$logAction = 'unprotect';
2516
		} elseif ( $isProtected ) {
2517
			$revCommentMsg = 'modifiedarticleprotection';
2518
			$logAction = 'modify';
2519
		} else {
2520
			$revCommentMsg = 'protectedarticle';
2521
			$logAction = 'protect';
2522
		}
2523
2524
		// Truncate for whole multibyte characters
2525
		$reason = $wgContLang->truncate( $reason, 255 );
2526
2527
		$logRelationsValues = [];
2528
		$logRelationsField = null;
2529
		$logParamsDetails = [];
2530
2531
		// Null revision (used for change tag insertion)
2532
		$nullRevision = null;
2533
2534
		if ( $id ) { // Protection of existing page
2535
			if ( !Hooks::run( 'ArticleProtect', [ &$this, &$user, $limit, $reason ] ) ) {
2536
				return Status::newGood();
2537
			}
2538
2539
			// Only certain restrictions can cascade...
2540
			$editrestriction = isset( $limit['edit'] )
2541
				? [ $limit['edit'] ]
2542
				: $this->mTitle->getRestrictions( 'edit' );
2543
			foreach ( array_keys( $editrestriction, 'sysop' ) as $key ) {
2544
				$editrestriction[$key] = 'editprotected'; // backwards compatibility
2545
			}
2546
			foreach ( array_keys( $editrestriction, 'autoconfirmed' ) as $key ) {
2547
				$editrestriction[$key] = 'editsemiprotected'; // backwards compatibility
2548
			}
2549
2550
			$cascadingRestrictionLevels = $wgCascadingRestrictionLevels;
2551
			foreach ( array_keys( $cascadingRestrictionLevels, 'sysop' ) as $key ) {
2552
				$cascadingRestrictionLevels[$key] = 'editprotected'; // backwards compatibility
2553
			}
2554
			foreach ( array_keys( $cascadingRestrictionLevels, 'autoconfirmed' ) as $key ) {
2555
				$cascadingRestrictionLevels[$key] = 'editsemiprotected'; // backwards compatibility
2556
			}
2557
2558
			// The schema allows multiple restrictions
2559
			if ( !array_intersect( $editrestriction, $cascadingRestrictionLevels ) ) {
2560
				$cascade = false;
2561
			}
2562
2563
			// insert null revision to identify the page protection change as edit summary
2564
			$latest = $this->getLatest();
2565
			$nullRevision = $this->insertProtectNullRevision(
2566
				$revCommentMsg,
2567
				$limit,
2568
				$expiry,
2569
				$cascade,
2570
				$reason,
2571
				$user
2572
			);
2573
2574
			if ( $nullRevision === null ) {
2575
				return Status::newFatal( 'no-null-revision', $this->mTitle->getPrefixedText() );
2576
			}
2577
2578
			$logRelationsField = 'pr_id';
2579
2580
			// Update restrictions table
2581
			foreach ( $limit as $action => $restrictions ) {
2582
				$dbw->delete(
2583
					'page_restrictions',
2584
					[
2585
						'pr_page' => $id,
2586
						'pr_type' => $action
2587
					],
2588
					__METHOD__
2589
				);
2590
				if ( $restrictions != '' ) {
2591
					$cascadeValue = ( $cascade && $action == 'edit' ) ? 1 : 0;
2592
					$dbw->insert(
2593
						'page_restrictions',
2594
						[
2595
							'pr_id' => $dbw->nextSequenceValue( 'page_restrictions_pr_id_seq' ),
2596
							'pr_page' => $id,
2597
							'pr_type' => $action,
2598
							'pr_level' => $restrictions,
2599
							'pr_cascade' => $cascadeValue,
2600
							'pr_expiry' => $dbw->encodeExpiry( $expiry[$action] )
2601
						],
2602
						__METHOD__
2603
					);
2604
					$logRelationsValues[] = $dbw->insertId();
2605
					$logParamsDetails[] = [
2606
						'type' => $action,
2607
						'level' => $restrictions,
2608
						'expiry' => $expiry[$action],
2609
						'cascade' => (bool)$cascadeValue,
2610
					];
2611
				}
2612
			}
2613
2614
			// Clear out legacy restriction fields
2615
			$dbw->update(
2616
				'page',
2617
				[ 'page_restrictions' => '' ],
2618
				[ 'page_id' => $id ],
2619
				__METHOD__
2620
			);
2621
2622
			Hooks::run( 'NewRevisionFromEditComplete',
2623
				[ $this, $nullRevision, $latest, $user ] );
2624
			Hooks::run( 'ArticleProtectComplete', [ &$this, &$user, $limit, $reason ] );
2625
		} else { // Protection of non-existing page (also known as "title protection")
2626
			// Cascade protection is meaningless in this case
2627
			$cascade = false;
2628
2629
			if ( $limit['create'] != '' ) {
2630
				$dbw->replace( 'protected_titles',
2631
					[ [ 'pt_namespace', 'pt_title' ] ],
2632
					[
2633
						'pt_namespace' => $this->mTitle->getNamespace(),
2634
						'pt_title' => $this->mTitle->getDBkey(),
2635
						'pt_create_perm' => $limit['create'],
2636
						'pt_timestamp' => $dbw->timestamp(),
2637
						'pt_expiry' => $dbw->encodeExpiry( $expiry['create'] ),
2638
						'pt_user' => $user->getId(),
2639
						'pt_reason' => $reason,
2640
					], __METHOD__
2641
				);
2642
				$logParamsDetails[] = [
2643
					'type' => 'create',
2644
					'level' => $limit['create'],
2645
					'expiry' => $expiry['create'],
2646
				];
2647
			} else {
2648
				$dbw->delete( 'protected_titles',
2649
					[
2650
						'pt_namespace' => $this->mTitle->getNamespace(),
2651
						'pt_title' => $this->mTitle->getDBkey()
2652
					], __METHOD__
2653
				);
2654
			}
2655
		}
2656
2657
		$this->mTitle->flushRestrictions();
2658
		InfoAction::invalidateCache( $this->mTitle );
2659
2660
		if ( $logAction == 'unprotect' ) {
2661
			$params = [];
2662
		} else {
2663
			$protectDescriptionLog = $this->protectDescriptionLog( $limit, $expiry );
2664
			$params = [
2665
				'4::description' => $protectDescriptionLog, // parameter for IRC
2666
				'5:bool:cascade' => $cascade,
2667
				'details' => $logParamsDetails, // parameter for localize and api
2668
			];
2669
		}
2670
2671
		// Update the protection log
2672
		$logEntry = new ManualLogEntry( 'protect', $logAction );
2673
		$logEntry->setTarget( $this->mTitle );
2674
		$logEntry->setComment( $reason );
2675
		$logEntry->setPerformer( $user );
2676
		$logEntry->setParameters( $params );
2677
		if ( !is_null( $nullRevision ) ) {
2678
			$logEntry->setAssociatedRevId( $nullRevision->getId() );
2679
		}
2680
		$logEntry->setTags( $tags );
0 ignored issues
show
Bug introduced by
It seems like $tags defined by parameter $tags on line 2449 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...
2681
		if ( $logRelationsField !== null && count( $logRelationsValues ) ) {
2682
			$logEntry->setRelations( [ $logRelationsField => $logRelationsValues ] );
2683
		}
2684
		$logId = $logEntry->insert();
2685
		$logEntry->publish( $logId );
2686
2687
		return Status::newGood( $logId );
2688
	}
2689
2690
	/**
2691
	 * Insert a new null revision for this page.
2692
	 *
2693
	 * @param string $revCommentMsg Comment message key for the revision
2694
	 * @param array $limit Set of restriction keys
2695
	 * @param array $expiry Per restriction type expiration
2696
	 * @param int $cascade Set to false if cascading protection isn't allowed.
2697
	 * @param string $reason
2698
	 * @param User|null $user
2699
	 * @return Revision|null Null on error
2700
	 */
2701
	public function insertProtectNullRevision( $revCommentMsg, array $limit,
2702
		array $expiry, $cascade, $reason, $user = null
2703
	) {
2704
		global $wgContLang;
2705
		$dbw = wfGetDB( DB_MASTER );
2706
2707
		// Prepare a null revision to be added to the history
2708
		$editComment = $wgContLang->ucfirst(
2709
			wfMessage(
2710
				$revCommentMsg,
2711
				$this->mTitle->getPrefixedText()
2712
			)->inContentLanguage()->text()
2713
		);
2714
		if ( $reason ) {
2715
			$editComment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
2716
		}
2717
		$protectDescription = $this->protectDescription( $limit, $expiry );
2718
		if ( $protectDescription ) {
2719
			$editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2720
			$editComment .= wfMessage( 'parentheses' )->params( $protectDescription )
2721
				->inContentLanguage()->text();
2722
		}
2723
		if ( $cascade ) {
2724
			$editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2725
			$editComment .= wfMessage( 'brackets' )->params(
2726
				wfMessage( 'protect-summary-cascade' )->inContentLanguage()->text()
2727
			)->inContentLanguage()->text();
2728
		}
2729
2730
		$nullRev = Revision::newNullRevision( $dbw, $this->getId(), $editComment, true, $user );
2731
		if ( $nullRev ) {
2732
			$nullRev->insertOn( $dbw );
2733
2734
			// Update page record and touch page
2735
			$oldLatest = $nullRev->getParentId();
2736
			$this->updateRevisionOn( $dbw, $nullRev, $oldLatest );
2737
		}
2738
2739
		return $nullRev;
2740
	}
2741
2742
	/**
2743
	 * @param string $expiry 14-char timestamp or "infinity", or false if the input was invalid
2744
	 * @return string
2745
	 */
2746
	protected function formatExpiry( $expiry ) {
2747
		global $wgContLang;
2748
2749
		if ( $expiry != 'infinity' ) {
2750
			return wfMessage(
2751
				'protect-expiring',
2752
				$wgContLang->timeanddate( $expiry, false, false ),
2753
				$wgContLang->date( $expiry, false, false ),
2754
				$wgContLang->time( $expiry, false, false )
2755
			)->inContentLanguage()->text();
2756
		} else {
2757
			return wfMessage( 'protect-expiry-indefinite' )
2758
				->inContentLanguage()->text();
2759
		}
2760
	}
2761
2762
	/**
2763
	 * Builds the description to serve as comment for the edit.
2764
	 *
2765
	 * @param array $limit Set of restriction keys
2766
	 * @param array $expiry Per restriction type expiration
2767
	 * @return string
2768
	 */
2769
	public function protectDescription( array $limit, array $expiry ) {
2770
		$protectDescription = '';
2771
2772
		foreach ( array_filter( $limit ) as $action => $restrictions ) {
2773
			# $action is one of $wgRestrictionTypes = [ 'create', 'edit', 'move', 'upload' ].
2774
			# All possible message keys are listed here for easier grepping:
2775
			# * restriction-create
2776
			# * restriction-edit
2777
			# * restriction-move
2778
			# * restriction-upload
2779
			$actionText = wfMessage( 'restriction-' . $action )->inContentLanguage()->text();
2780
			# $restrictions is one of $wgRestrictionLevels = [ '', 'autoconfirmed', 'sysop' ],
2781
			# with '' filtered out. All possible message keys are listed below:
2782
			# * protect-level-autoconfirmed
2783
			# * protect-level-sysop
2784
			$restrictionsText = wfMessage( 'protect-level-' . $restrictions )
2785
				->inContentLanguage()->text();
2786
2787
			$expiryText = $this->formatExpiry( $expiry[$action] );
2788
2789
			if ( $protectDescription !== '' ) {
2790
				$protectDescription .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2791
			}
2792
			$protectDescription .= wfMessage( 'protect-summary-desc' )
2793
				->params( $actionText, $restrictionsText, $expiryText )
2794
				->inContentLanguage()->text();
2795
		}
2796
2797
		return $protectDescription;
2798
	}
2799
2800
	/**
2801
	 * Builds the description to serve as comment for the log entry.
2802
	 *
2803
	 * Some bots may parse IRC lines, which are generated from log entries which contain plain
2804
	 * protect description text. Keep them in old format to avoid breaking compatibility.
2805
	 * TODO: Fix protection log to store structured description and format it on-the-fly.
2806
	 *
2807
	 * @param array $limit Set of restriction keys
2808
	 * @param array $expiry Per restriction type expiration
2809
	 * @return string
2810
	 */
2811
	public function protectDescriptionLog( array $limit, array $expiry ) {
2812
		global $wgContLang;
2813
2814
		$protectDescriptionLog = '';
2815
2816
		foreach ( array_filter( $limit ) as $action => $restrictions ) {
2817
			$expiryText = $this->formatExpiry( $expiry[$action] );
2818
			$protectDescriptionLog .= $wgContLang->getDirMark() .
2819
				"[$action=$restrictions] ($expiryText)";
2820
		}
2821
2822
		return trim( $protectDescriptionLog );
2823
	}
2824
2825
	/**
2826
	 * Take an array of page restrictions and flatten it to a string
2827
	 * suitable for insertion into the page_restrictions field.
2828
	 *
2829
	 * @param string[] $limit
2830
	 *
2831
	 * @throws MWException
2832
	 * @return string
2833
	 */
2834
	protected static function flattenRestrictions( $limit ) {
2835
		if ( !is_array( $limit ) ) {
2836
			throw new MWException( __METHOD__ . ' given non-array restriction set' );
2837
		}
2838
2839
		$bits = [];
2840
		ksort( $limit );
2841
2842
		foreach ( array_filter( $limit ) as $action => $restrictions ) {
2843
			$bits[] = "$action=$restrictions";
2844
		}
2845
2846
		return implode( ':', $bits );
2847
	}
2848
2849
	/**
2850
	 * Same as doDeleteArticleReal(), but returns a simple boolean. This is kept around for
2851
	 * backwards compatibility, if you care about error reporting you should use
2852
	 * doDeleteArticleReal() instead.
2853
	 *
2854
	 * Deletes the article with database consistency, writes logs, purges caches
2855
	 *
2856
	 * @param string $reason Delete reason for deletion log
2857
	 * @param bool $suppress Suppress all revisions and log the deletion in
2858
	 *        the suppression log instead of the deletion log
2859
	 * @param int $u1 Unused
2860
	 * @param bool $u2 Unused
2861
	 * @param array|string &$error Array of errors to append to
2862
	 * @param User $user The deleting user
2863
	 * @return bool True if successful
2864
	 */
2865
	public function doDeleteArticle(
2866
		$reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null
2867
	) {
2868
		$status = $this->doDeleteArticleReal( $reason, $suppress, $u1, $u2, $error, $user );
2869
		return $status->isGood();
2870
	}
2871
2872
	/**
2873
	 * Back-end article deletion
2874
	 * Deletes the article with database consistency, writes logs, purges caches
2875
	 *
2876
	 * @since 1.19
2877
	 *
2878
	 * @param string $reason Delete reason for deletion log
2879
	 * @param bool $suppress Suppress all revisions and log the deletion in
2880
	 *   the suppression log instead of the deletion log
2881
	 * @param int $u1 Unused
2882
	 * @param bool $u2 Unused
2883
	 * @param array|string &$error Array of errors to append to
2884
	 * @param User $user The deleting user
2885
	 * @return Status Status object; if successful, $status->value is the log_id of the
2886
	 *   deletion log entry. If the page couldn't be deleted because it wasn't
2887
	 *   found, $status is a non-fatal 'cannotdelete' error
2888
	 */
2889
	public function doDeleteArticleReal(
2890
		$reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null
2891
	) {
2892
		global $wgUser, $wgContentHandlerUseDB;
2893
2894
		wfDebug( __METHOD__ . "\n" );
2895
2896
		$status = Status::newGood();
2897
2898
		if ( $this->mTitle->getDBkey() === '' ) {
2899
			$status->error( 'cannotdelete',
2900
				wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
2901
			return $status;
2902
		}
2903
2904
		$user = is_null( $user ) ? $wgUser : $user;
2905
		if ( !Hooks::run( 'ArticleDelete',
2906
			[ &$this, &$user, &$reason, &$error, &$status, $suppress ]
2907
		) ) {
2908
			if ( $status->isOK() ) {
2909
				// Hook aborted but didn't set a fatal status
2910
				$status->fatal( 'delete-hook-aborted' );
2911
			}
2912
			return $status;
2913
		}
2914
2915
		$dbw = wfGetDB( DB_MASTER );
2916
		$dbw->startAtomic( __METHOD__ );
2917
2918
		$this->loadPageData( self::READ_LATEST );
2919
		$id = $this->getId();
2920
		// T98706: lock the page from various other updates but avoid using
2921
		// WikiPage::READ_LOCKING as that will carry over the FOR UPDATE to
2922
		// the revisions queries (which also JOIN on user). Only lock the page
2923
		// row and CAS check on page_latest to see if the trx snapshot matches.
2924
		$lockedLatest = $this->lockAndGetLatest();
2925
		if ( $id == 0 || $this->getLatest() != $lockedLatest ) {
2926
			$dbw->endAtomic( __METHOD__ );
2927
			// Page not there or trx snapshot is stale
2928
			$status->error( 'cannotdelete',
2929
				wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
2930
			return $status;
2931
		}
2932
2933
		// Given the lock above, we can be confident in the title and page ID values
2934
		$namespace = $this->getTitle()->getNamespace();
2935
		$dbKey = $this->getTitle()->getDBkey();
2936
2937
		// At this point we are now comitted to returning an OK
2938
		// status unless some DB query error or other exception comes up.
2939
		// This way callers don't have to call rollback() if $status is bad
2940
		// unless they actually try to catch exceptions (which is rare).
2941
2942
		// we need to remember the old content so we can use it to generate all deletion updates.
2943
		try {
2944
			$content = $this->getContent( Revision::RAW );
2945
		} catch ( Exception $ex ) {
2946
			wfLogWarning( __METHOD__ . ': failed to load content during deletion! '
2947
				. $ex->getMessage() );
2948
2949
			$content = null;
2950
		}
2951
2952
		// Bitfields to further suppress the content
2953
		if ( $suppress ) {
2954
			$bitfield = 0;
2955
			// This should be 15...
2956
			$bitfield |= Revision::DELETED_TEXT;
2957
			$bitfield |= Revision::DELETED_COMMENT;
2958
			$bitfield |= Revision::DELETED_USER;
2959
			$bitfield |= Revision::DELETED_RESTRICTED;
2960
			$deletionFields = [ $dbw->addQuotes( $bitfield ) . ' AS deleted' ];
2961
		} else {
2962
			$deletionFields = [ 'rev_deleted AS deleted' ];
2963
		}
2964
2965
		// For now, shunt the revision data into the archive table.
2966
		// Text is *not* removed from the text table; bulk storage
2967
		// is left intact to avoid breaking block-compression or
2968
		// immutable storage schemes.
2969
		// In the future, we may keep revisions and mark them with
2970
		// the rev_deleted field, which is reserved for this purpose.
2971
2972
		// Get all of the page revisions
2973
		$fields = array_diff( Revision::selectFields(), [ 'rev_deleted' ] );
2974
		$res = $dbw->select(
2975
			'revision',
2976
			array_merge( $fields, $deletionFields ),
2977
			[ 'rev_page' => $id ],
2978
			__METHOD__,
2979
			'FOR UPDATE'
2980
		);
2981
		// Build their equivalent archive rows
2982
		$rowsInsert = [];
2983
		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...
2984
			$rowInsert = [
2985
				'ar_namespace'  => $namespace,
2986
				'ar_title'      => $dbKey,
2987
				'ar_comment'    => $row->rev_comment,
2988
				'ar_user'       => $row->rev_user,
2989
				'ar_user_text'  => $row->rev_user_text,
2990
				'ar_timestamp'  => $row->rev_timestamp,
2991
				'ar_minor_edit' => $row->rev_minor_edit,
2992
				'ar_rev_id'     => $row->rev_id,
2993
				'ar_parent_id'  => $row->rev_parent_id,
2994
				'ar_text_id'    => $row->rev_text_id,
2995
				'ar_text'       => '',
2996
				'ar_flags'      => '',
2997
				'ar_len'        => $row->rev_len,
2998
				'ar_page_id'    => $id,
2999
				'ar_deleted'    => $row->deleted,
3000
				'ar_sha1'       => $row->rev_sha1,
3001
			];
3002
			if ( $wgContentHandlerUseDB ) {
3003
				$rowInsert['ar_content_model'] = $row->rev_content_model;
3004
				$rowInsert['ar_content_format'] = $row->rev_content_format;
3005
			}
3006
			$rowsInsert[] = $rowInsert;
3007
		}
3008
		// Copy them into the archive table
3009
		$dbw->insert( 'archive', $rowsInsert, __METHOD__ );
3010
		// Save this so we can pass it to the ArticleDeleteComplete hook.
3011
		$archivedRevisionCount = $dbw->affectedRows();
3012
3013
		// Clone the title and wikiPage, so we have the information we need when
3014
		// we log and run the ArticleDeleteComplete hook.
3015
		$logTitle = clone $this->mTitle;
3016
		$wikiPageBeforeDelete = clone $this;
3017
3018
		// Now that it's safely backed up, delete it
3019
		$dbw->delete( 'page', [ 'page_id' => $id ], __METHOD__ );
3020
		$dbw->delete( 'revision', [ 'rev_page' => $id ], __METHOD__ );
3021
3022
		// Log the deletion, if the page was suppressed, put it in the suppression log instead
3023
		$logtype = $suppress ? 'suppress' : 'delete';
3024
3025
		$logEntry = new ManualLogEntry( $logtype, 'delete' );
3026
		$logEntry->setPerformer( $user );
3027
		$logEntry->setTarget( $logTitle );
3028
		$logEntry->setComment( $reason );
3029
		$logid = $logEntry->insert();
3030
3031
		$dbw->onTransactionPreCommitOrIdle(
3032
			function () use ( $dbw, $logEntry, $logid ) {
3033
				// Bug 56776: avoid deadlocks (especially from FileDeleteForm)
3034
				$logEntry->publish( $logid );
3035
			},
3036
			__METHOD__
3037
		);
3038
3039
		$dbw->endAtomic( __METHOD__ );
3040
3041
		$this->doDeleteUpdates( $id, $content );
3042
3043
		Hooks::run( 'ArticleDeleteComplete', [
3044
			&$wikiPageBeforeDelete,
3045
			&$user,
3046
			$reason,
3047
			$id,
3048
			$content,
3049
			$logEntry,
3050
			$archivedRevisionCount
3051
		] );
3052
		$status->value = $logid;
3053
3054
		// Show log excerpt on 404 pages rather than just a link
3055
		$cache = ObjectCache::getMainStashInstance();
3056
		$key = wfMemcKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
3057
		$cache->set( $key, 1, $cache::TTL_DAY );
3058
3059
		return $status;
3060
	}
3061
3062
	/**
3063
	 * Lock the page row for this title+id and return page_latest (or 0)
3064
	 *
3065
	 * @return integer Returns 0 if no row was found with this title+id
3066
	 * @since 1.27
3067
	 */
3068
	public function lockAndGetLatest() {
3069
		return (int)wfGetDB( DB_MASTER )->selectField(
3070
			'page',
3071
			'page_latest',
3072
			[
3073
				'page_id' => $this->getId(),
3074
				// Typically page_id is enough, but some code might try to do
3075
				// updates assuming the title is the same, so verify that
3076
				'page_namespace' => $this->getTitle()->getNamespace(),
3077
				'page_title' => $this->getTitle()->getDBkey()
3078
			],
3079
			__METHOD__,
3080
			[ 'FOR UPDATE' ]
3081
		);
3082
	}
3083
3084
	/**
3085
	 * Do some database updates after deletion
3086
	 *
3087
	 * @param int $id The page_id value of the page being deleted
3088
	 * @param Content $content Optional page content to be used when determining
3089
	 *   the required updates. This may be needed because $this->getContent()
3090
	 *   may already return null when the page proper was deleted.
3091
	 */
3092
	public function doDeleteUpdates( $id, Content $content = null ) {
3093
		try {
3094
			$countable = $this->isCountable();
3095
		} catch ( Exception $ex ) {
3096
			// fallback for deleting broken pages for which we cannot load the content for
3097
			// some reason. Note that doDeleteArticleReal() already logged this problem.
3098
			$countable = false;
3099
		}
3100
3101
		// Update site status
3102
		DeferredUpdates::addUpdate( new SiteStatsUpdate( 0, 1, - (int)$countable, -1 ) );
3103
3104
		// Delete pagelinks, update secondary indexes, etc
3105
		$updates = $this->getDeletionUpdates( $content );
3106
		foreach ( $updates as $update ) {
3107
			DeferredUpdates::addUpdate( $update );
3108
		}
3109
3110
		// Reparse any pages transcluding this page
3111
		LinksUpdate::queueRecursiveJobsForTable( $this->mTitle, 'templatelinks' );
3112
3113
		// Reparse any pages including this image
3114
		if ( $this->mTitle->getNamespace() == NS_FILE ) {
3115
			LinksUpdate::queueRecursiveJobsForTable( $this->mTitle, 'imagelinks' );
3116
		}
3117
3118
		// Clear caches
3119
		WikiPage::onArticleDelete( $this->mTitle );
3120
3121
		// Reset this object and the Title object
3122
		$this->loadFromRow( false, self::READ_LATEST );
3123
3124
		// Search engine
3125
		DeferredUpdates::addUpdate( new SearchUpdate( $id, $this->mTitle ) );
3126
	}
3127
3128
	/**
3129
	 * Roll back the most recent consecutive set of edits to a page
3130
	 * from the same user; fails if there are no eligible edits to
3131
	 * roll back to, e.g. user is the sole contributor. This function
3132
	 * performs permissions checks on $user, then calls commitRollback()
3133
	 * to do the dirty work
3134
	 *
3135
	 * @todo Separate the business/permission stuff out from backend code
3136
	 * @todo Remove $token parameter. Already verified by RollbackAction and ApiRollback.
3137
	 *
3138
	 * @param string $fromP Name of the user whose edits to rollback.
3139
	 * @param string $summary Custom summary. Set to default summary if empty.
3140
	 * @param string $token Rollback token.
3141
	 * @param bool $bot If true, mark all reverted edits as bot.
3142
	 *
3143
	 * @param array $resultDetails Array contains result-specific array of additional values
3144
	 *    'alreadyrolled' : 'current' (rev)
3145
	 *    success        : 'summary' (str), 'current' (rev), 'target' (rev)
3146
	 *
3147
	 * @param User $user The user performing the rollback
3148
	 * @param array|null $tags Change tags to apply to the rollback
3149
	 * Callers are responsible for permission checks
3150
	 * (with ChangeTags::canAddTagsAccompanyingChange)
3151
	 *
3152
	 * @return array Array of errors, each error formatted as
3153
	 *   array(messagekey, param1, param2, ...).
3154
	 * On success, the array is empty.  This array can also be passed to
3155
	 * OutputPage::showPermissionsErrorPage().
3156
	 */
3157
	public function doRollback(
3158
		$fromP, $summary, $token, $bot, &$resultDetails, User $user, $tags = null
3159
	) {
3160
		$resultDetails = null;
3161
3162
		// Check permissions
3163
		$editErrors = $this->mTitle->getUserPermissionsErrors( 'edit', $user );
3164
		$rollbackErrors = $this->mTitle->getUserPermissionsErrors( 'rollback', $user );
3165
		$errors = array_merge( $editErrors, wfArrayDiff2( $rollbackErrors, $editErrors ) );
3166
3167
		if ( !$user->matchEditToken( $token, 'rollback' ) ) {
3168
			$errors[] = [ 'sessionfailure' ];
3169
		}
3170
3171
		if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) {
3172
			$errors[] = [ 'actionthrottledtext' ];
3173
		}
3174
3175
		// If there were errors, bail out now
3176
		if ( !empty( $errors ) ) {
3177
			return $errors;
3178
		}
3179
3180
		return $this->commitRollback( $fromP, $summary, $bot, $resultDetails, $user, $tags );
3181
	}
3182
3183
	/**
3184
	 * Backend implementation of doRollback(), please refer there for parameter
3185
	 * and return value documentation
3186
	 *
3187
	 * NOTE: This function does NOT check ANY permissions, it just commits the
3188
	 * rollback to the DB. Therefore, you should only call this function direct-
3189
	 * ly if you want to use custom permissions checks. If you don't, use
3190
	 * doRollback() instead.
3191
	 * @param string $fromP Name of the user whose edits to rollback.
3192
	 * @param string $summary Custom summary. Set to default summary if empty.
3193
	 * @param bool $bot If true, mark all reverted edits as bot.
3194
	 *
3195
	 * @param array $resultDetails Contains result-specific array of additional values
3196
	 * @param User $guser The user performing the rollback
3197
	 * @param array|null $tags Change tags to apply to the rollback
3198
	 * Callers are responsible for permission checks
3199
	 * (with ChangeTags::canAddTagsAccompanyingChange)
3200
	 *
3201
	 * @return array
3202
	 */
3203
	public function commitRollback( $fromP, $summary, $bot,
3204
		&$resultDetails, User $guser, $tags = null
3205
	) {
3206
		global $wgUseRCPatrol, $wgContLang;
3207
3208
		$dbw = wfGetDB( DB_MASTER );
3209
3210
		if ( wfReadOnly() ) {
3211
			return [ [ 'readonlytext' ] ];
3212
		}
3213
3214
		// Get the last editor
3215
		$current = $this->getRevision();
3216
		if ( is_null( $current ) ) {
3217
			// Something wrong... no page?
3218
			return [ [ 'notanarticle' ] ];
3219
		}
3220
3221
		$from = str_replace( '_', ' ', $fromP );
3222
		// User name given should match up with the top revision.
3223
		// If the user was deleted then $from should be empty.
3224 View Code Duplication
		if ( $from != $current->getUserText() ) {
3225
			$resultDetails = [ 'current' => $current ];
3226
			return [ [ 'alreadyrolled',
3227
				htmlspecialchars( $this->mTitle->getPrefixedText() ),
3228
				htmlspecialchars( $fromP ),
3229
				htmlspecialchars( $current->getUserText() )
3230
			] ];
3231
		}
3232
3233
		// Get the last edit not by this person...
3234
		// Note: these may not be public values
3235
		$user = intval( $current->getUser( Revision::RAW ) );
3236
		$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, Database::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...
3237
		$s = $dbw->selectRow( 'revision',
3238
			[ 'rev_id', 'rev_timestamp', 'rev_deleted' ],
3239
			[ 'rev_page' => $current->getPage(),
3240
				"rev_user != {$user} OR rev_user_text != {$user_text}"
3241
			], __METHOD__,
3242
			[ 'USE INDEX' => 'page_timestamp',
3243
				'ORDER BY' => 'rev_timestamp DESC' ]
3244
			);
3245
		if ( $s === false ) {
3246
			// No one else ever edited this page
3247
			return [ [ 'cantrollback' ] ];
3248
		} elseif ( $s->rev_deleted & Revision::DELETED_TEXT
3249
			|| $s->rev_deleted & Revision::DELETED_USER
3250
		) {
3251
			// Only admins can see this text
3252
			return [ [ 'notvisiblerev' ] ];
3253
		}
3254
3255
		// Generate the edit summary if necessary
3256
		$target = Revision::newFromId( $s->rev_id, Revision::READ_LATEST );
3257
		if ( empty( $summary ) ) {
3258
			if ( $from == '' ) { // no public user name
3259
				$summary = wfMessage( 'revertpage-nouser' );
3260
			} else {
3261
				$summary = wfMessage( 'revertpage' );
3262
			}
3263
		}
3264
3265
		// Allow the custom summary to use the same args as the default message
3266
		$args = [
3267
			$target->getUserText(), $from, $s->rev_id,
3268
			$wgContLang->timeanddate( wfTimestamp( TS_MW, $s->rev_timestamp ) ),
3269
			$current->getId(), $wgContLang->timeanddate( $current->getTimestamp() )
3270
		];
3271
		if ( $summary instanceof Message ) {
3272
			$summary = $summary->params( $args )->inContentLanguage()->text();
3273
		} else {
3274
			$summary = wfMsgReplaceArgs( $summary, $args );
3275
		}
3276
3277
		// Trim spaces on user supplied text
3278
		$summary = trim( $summary );
3279
3280
		// Truncate for whole multibyte characters.
3281
		$summary = $wgContLang->truncate( $summary, 255 );
3282
3283
		// Save
3284
		$flags = EDIT_UPDATE | EDIT_INTERNAL;
3285
3286
		if ( $guser->isAllowed( 'minoredit' ) ) {
3287
			$flags |= EDIT_MINOR;
3288
		}
3289
3290
		if ( $bot && ( $guser->isAllowedAny( 'markbotedits', 'bot' ) ) ) {
3291
			$flags |= EDIT_FORCE_BOT;
3292
		}
3293
3294
		$targetContent = $target->getContent();
3295
		$changingContentModel = $targetContent->getModel() !== $current->getContentModel();
3296
3297
		// Actually store the edit
3298
		$status = $this->doEditContent(
3299
			$targetContent,
3300
			$summary,
3301
			$flags,
3302
			$target->getId(),
3303
			$guser,
3304
			null,
3305
			$tags
3306
		);
3307
3308
		// Set patrolling and bot flag on the edits, which gets rollbacked.
3309
		// This is done even on edit failure to have patrolling in that case (bug 62157).
3310
		$set = [];
3311
		if ( $bot && $guser->isAllowed( 'markbotedits' ) ) {
3312
			// Mark all reverted edits as bot
3313
			$set['rc_bot'] = 1;
3314
		}
3315
3316
		if ( $wgUseRCPatrol ) {
3317
			// Mark all reverted edits as patrolled
3318
			$set['rc_patrolled'] = 1;
3319
		}
3320
3321
		if ( count( $set ) ) {
3322
			$dbw->update( 'recentchanges', $set,
3323
				[ /* WHERE */
3324
					'rc_cur_id' => $current->getPage(),
3325
					'rc_user_text' => $current->getUserText(),
3326
					'rc_timestamp > ' . $dbw->addQuotes( $s->rev_timestamp ),
3327
				],
3328
				__METHOD__
3329
			);
3330
		}
3331
3332
		if ( !$status->isOK() ) {
3333
			return $status->getErrorsArray();
3334
		}
3335
3336
		// raise error, when the edit is an edit without a new version
3337
		$statusRev = isset( $status->value['revision'] )
3338
			? $status->value['revision']
3339
			: null;
3340 View Code Duplication
		if ( !( $statusRev instanceof Revision ) ) {
3341
			$resultDetails = [ 'current' => $current ];
3342
			return [ [ 'alreadyrolled',
3343
					htmlspecialchars( $this->mTitle->getPrefixedText() ),
3344
					htmlspecialchars( $fromP ),
3345
					htmlspecialchars( $current->getUserText() )
3346
			] ];
3347
		}
3348
3349
		if ( $changingContentModel ) {
3350
			// If the content model changed during the rollback,
3351
			// make sure it gets logged to Special:Log/contentmodel
3352
			$log = new ManualLogEntry( 'contentmodel', 'change' );
3353
			$log->setPerformer( $guser );
3354
			$log->setTarget( $this->mTitle );
3355
			$log->setComment( $summary );
3356
			$log->setParameters( [
3357
				'4::oldmodel' => $current->getContentModel(),
3358
				'5::newmodel' => $targetContent->getModel(),
3359
			] );
3360
3361
			$logId = $log->insert( $dbw );
3362
			$log->publish( $logId );
3363
		}
3364
3365
		$revId = $statusRev->getId();
3366
3367
		Hooks::run( 'ArticleRollbackComplete', [ $this, $guser, $target, $current ] );
3368
3369
		$resultDetails = [
3370
			'summary' => $summary,
3371
			'current' => $current,
3372
			'target' => $target,
3373
			'newid' => $revId
3374
		];
3375
3376
		return [];
3377
	}
3378
3379
	/**
3380
	 * The onArticle*() functions are supposed to be a kind of hooks
3381
	 * which should be called whenever any of the specified actions
3382
	 * are done.
3383
	 *
3384
	 * This is a good place to put code to clear caches, for instance.
3385
	 *
3386
	 * This is called on page move and undelete, as well as edit
3387
	 *
3388
	 * @param Title $title
3389
	 */
3390
	public static function onArticleCreate( Title $title ) {
3391
		// Update existence markers on article/talk tabs...
3392
		$other = $title->getOtherPage();
3393
3394
		$other->purgeSquid();
3395
3396
		$title->touchLinks();
3397
		$title->purgeSquid();
3398
		$title->deleteTitleProtection();
3399
3400
		MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
3401
3402
		if ( $title->getNamespace() == NS_CATEGORY ) {
3403
			// Load the Category object, which will schedule a job to create
3404
			// the category table row if necessary. Checking a replica DB is ok
3405
			// here, in the worst case it'll run an unnecessary recount job on
3406
			// a category that probably doesn't have many members.
3407
			Category::newFromTitle( $title )->getID();
3408
		}
3409
	}
3410
3411
	/**
3412
	 * Clears caches when article is deleted
3413
	 *
3414
	 * @param Title $title
3415
	 */
3416
	public static function onArticleDelete( Title $title ) {
3417
		global $wgContLang;
3418
3419
		// Update existence markers on article/talk tabs...
3420
		$other = $title->getOtherPage();
3421
3422
		$other->purgeSquid();
3423
3424
		$title->touchLinks();
3425
		$title->purgeSquid();
3426
3427
		MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
3428
3429
		// File cache
3430
		HTMLFileCache::clearFileCache( $title );
3431
		InfoAction::invalidateCache( $title );
3432
3433
		// Messages
3434
		if ( $title->getNamespace() == NS_MEDIAWIKI ) {
3435
			MessageCache::singleton()->replace( $title->getDBkey(), false );
3436
3437
			if ( $wgContLang->hasVariants() ) {
3438
				$wgContLang->updateConversionTable( $title );
3439
			}
3440
		}
3441
3442
		// Images
3443
		if ( $title->getNamespace() == NS_FILE ) {
3444
			DeferredUpdates::addUpdate( new HTMLCacheUpdate( $title, 'imagelinks' ) );
3445
		}
3446
3447
		// User talk pages
3448
		if ( $title->getNamespace() == NS_USER_TALK ) {
3449
			$user = User::newFromName( $title->getText(), false );
3450
			if ( $user ) {
3451
				$user->setNewtalk( false );
3452
			}
3453
		}
3454
3455
		// Image redirects
3456
		RepoGroup::singleton()->getLocalRepo()->invalidateImageRedirect( $title );
3457
	}
3458
3459
	/**
3460
	 * Purge caches on page update etc
3461
	 *
3462
	 * @param Title $title
3463
	 * @param Revision|null $revision Revision that was just saved, may be null
3464
	 */
3465
	public static function onArticleEdit( Title $title, Revision $revision = null ) {
3466
		// Invalidate caches of articles which include this page
3467
		DeferredUpdates::addUpdate( new HTMLCacheUpdate( $title, 'templatelinks' ) );
3468
3469
		// Invalidate the caches of all pages which redirect here
3470
		DeferredUpdates::addUpdate( new HTMLCacheUpdate( $title, 'redirect' ) );
3471
3472
		MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
3473
3474
		// Purge CDN for this page only
3475
		$title->purgeSquid();
3476
		// Clear file cache for this page only
3477
		HTMLFileCache::clearFileCache( $title );
3478
3479
		$revid = $revision ? $revision->getId() : null;
3480
		DeferredUpdates::addCallableUpdate( function() use ( $title, $revid ) {
3481
			InfoAction::invalidateCache( $title, $revid );
3482
		} );
3483
	}
3484
3485
	/**#@-*/
3486
3487
	/**
3488
	 * Returns a list of categories this page is a member of.
3489
	 * Results will include hidden categories
3490
	 *
3491
	 * @return TitleArray
3492
	 */
3493
	public function getCategories() {
3494
		$id = $this->getId();
3495
		if ( $id == 0 ) {
3496
			return TitleArray::newFromResult( new FakeResultWrapper( [] ) );
3497
		}
3498
3499
		$dbr = wfGetDB( DB_REPLICA );
3500
		$res = $dbr->select( 'categorylinks',
3501
			[ 'cl_to AS page_title, ' . NS_CATEGORY . ' AS page_namespace' ],
3502
			// Have to do that since DatabaseBase::fieldNamesWithAlias treats numeric indexes
3503
			// as not being aliases, and NS_CATEGORY is numeric
3504
			[ 'cl_from' => $id ],
3505
			__METHOD__ );
3506
3507
		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 3500 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...
3508
	}
3509
3510
	/**
3511
	 * Returns a list of hidden categories this page is a member of.
3512
	 * Uses the page_props and categorylinks tables.
3513
	 *
3514
	 * @return array Array of Title objects
3515
	 */
3516
	public function getHiddenCategories() {
3517
		$result = [];
3518
		$id = $this->getId();
3519
3520
		if ( $id == 0 ) {
3521
			return [];
3522
		}
3523
3524
		$dbr = wfGetDB( DB_REPLICA );
3525
		$res = $dbr->select( [ 'categorylinks', 'page_props', 'page' ],
3526
			[ 'cl_to' ],
3527
			[ 'cl_from' => $id, 'pp_page=page_id', 'pp_propname' => 'hiddencat',
3528
				'page_namespace' => NS_CATEGORY, 'page_title=cl_to' ],
3529
			__METHOD__ );
3530
3531
		if ( $res !== false ) {
3532
			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...
3533
				$result[] = Title::makeTitle( NS_CATEGORY, $row->cl_to );
3534
			}
3535
		}
3536
3537
		return $result;
3538
	}
3539
3540
	/**
3541
	 * Return an applicable autosummary if one exists for the given edit.
3542
	 * @param string|null $oldtext The previous text of the page.
3543
	 * @param string|null $newtext The submitted text of the page.
3544
	 * @param int $flags Bitmask: a bitmask of flags submitted for the edit.
3545
	 * @return string An appropriate autosummary, or an empty string.
3546
	 *
3547
	 * @deprecated since 1.21, use ContentHandler::getAutosummary() instead
3548
	 */
3549
	public static function getAutosummary( $oldtext, $newtext, $flags ) {
3550
		// NOTE: stub for backwards-compatibility. assumes the given text is
3551
		// wikitext. will break horribly if it isn't.
3552
3553
		ContentHandler::deprecated( __METHOD__, '1.21' );
3554
3555
		$handler = ContentHandler::getForModelID( CONTENT_MODEL_WIKITEXT );
3556
		$oldContent = is_null( $oldtext ) ? null : $handler->unserializeContent( $oldtext );
3557
		$newContent = is_null( $newtext ) ? null : $handler->unserializeContent( $newtext );
3558
3559
		return $handler->getAutosummary( $oldContent, $newContent, $flags );
3560
	}
3561
3562
	/**
3563
	 * Auto-generates a deletion reason
3564
	 *
3565
	 * @param bool &$hasHistory Whether the page has a history
3566
	 * @return string|bool String containing deletion reason or empty string, or boolean false
3567
	 *    if no revision occurred
3568
	 */
3569
	public function getAutoDeleteReason( &$hasHistory ) {
3570
		return $this->getContentHandler()->getAutoDeleteReason( $this->getTitle(), $hasHistory );
3571
	}
3572
3573
	/**
3574
	 * Update all the appropriate counts in the category table, given that
3575
	 * we've added the categories $added and deleted the categories $deleted.
3576
	 *
3577
	 * @param array $added The names of categories that were added
3578
	 * @param array $deleted The names of categories that were deleted
3579
	 * @param integer $id Page ID (this should be the original deleted page ID)
3580
	 */
3581
	public function updateCategoryCounts( array $added, array $deleted, $id = 0 ) {
3582
		$id = $id ?: $this->getId();
3583
		$dbw = wfGetDB( DB_MASTER );
3584
		$method = __METHOD__;
3585
		// Do this at the end of the commit to reduce lock wait timeouts
3586
		$dbw->onTransactionPreCommitOrIdle(
3587
			function () use ( $dbw, $added, $deleted, $id, $method ) {
3588
				$ns = $this->getTitle()->getNamespace();
3589
3590
				$addFields = [ 'cat_pages = cat_pages + 1' ];
3591
				$removeFields = [ 'cat_pages = cat_pages - 1' ];
3592
				if ( $ns == NS_CATEGORY ) {
3593
					$addFields[] = 'cat_subcats = cat_subcats + 1';
3594
					$removeFields[] = 'cat_subcats = cat_subcats - 1';
3595
				} elseif ( $ns == NS_FILE ) {
3596
					$addFields[] = 'cat_files = cat_files + 1';
3597
					$removeFields[] = 'cat_files = cat_files - 1';
3598
				}
3599
3600
				if ( count( $added ) ) {
3601
					$existingAdded = $dbw->selectFieldValues(
3602
						'category',
3603
						'cat_title',
3604
						[ 'cat_title' => $added ],
3605
						$method
3606
					);
3607
3608
					// For category rows that already exist, do a plain
3609
					// UPDATE instead of INSERT...ON DUPLICATE KEY UPDATE
3610
					// to avoid creating gaps in the cat_id sequence.
3611
					if ( count( $existingAdded ) ) {
3612
						$dbw->update(
3613
							'category',
3614
							$addFields,
3615
							[ 'cat_title' => $existingAdded ],
3616
							$method
3617
						);
3618
					}
3619
3620
					$missingAdded = array_diff( $added, $existingAdded );
3621
					if ( count( $missingAdded ) ) {
3622
						$insertRows = [];
3623
						foreach ( $missingAdded as $cat ) {
3624
							$insertRows[] = [
3625
								'cat_title'   => $cat,
3626
								'cat_pages'   => 1,
3627
								'cat_subcats' => ( $ns == NS_CATEGORY ) ? 1 : 0,
3628
								'cat_files'   => ( $ns == NS_FILE ) ? 1 : 0,
3629
							];
3630
						}
3631
						$dbw->upsert(
3632
							'category',
3633
							$insertRows,
3634
							[ 'cat_title' ],
3635
							$addFields,
3636
							$method
3637
						);
3638
					}
3639
				}
3640
3641
				if ( count( $deleted ) ) {
3642
					$dbw->update(
3643
						'category',
3644
						$removeFields,
3645
						[ 'cat_title' => $deleted ],
3646
						$method
3647
					);
3648
				}
3649
3650
				foreach ( $added as $catName ) {
3651
					$cat = Category::newFromName( $catName );
3652
					Hooks::run( 'CategoryAfterPageAdded', [ $cat, $this ] );
3653
				}
3654
3655
				foreach ( $deleted as $catName ) {
3656
					$cat = Category::newFromName( $catName );
3657
					Hooks::run( 'CategoryAfterPageRemoved', [ $cat, $this, $id ] );
3658
				}
3659
3660
				// Refresh counts on categories that should be empty now, to
3661
				// trigger possible deletion. Check master for the most
3662
				// up-to-date cat_pages.
3663
				if ( count( $deleted ) ) {
3664
					$rows = $dbw->select(
3665
						'category',
3666
						[ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ],
3667
						[ 'cat_title' => $deleted, 'cat_pages <= 0' ],
3668
						$method
3669
					);
3670
					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...
3671
						$cat = Category::newFromRow( $row );
3672
						$cat->refreshCounts();
3673
					}
3674
				}
3675
			},
3676
			__METHOD__
3677
		);
3678
	}
3679
3680
	/**
3681
	 * Opportunistically enqueue link update jobs given fresh parser output if useful
3682
	 *
3683
	 * @param ParserOutput $parserOutput Current version page output
3684
	 * @since 1.25
3685
	 */
3686
	public function triggerOpportunisticLinksUpdate( ParserOutput $parserOutput ) {
3687
		if ( wfReadOnly() ) {
3688
			return;
3689
		}
3690
3691
		if ( !Hooks::run( 'OpportunisticLinksUpdate',
3692
			[ $this, $this->mTitle, $parserOutput ]
3693
		) ) {
3694
			return;
3695
		}
3696
3697
		$config = RequestContext::getMain()->getConfig();
3698
3699
		$params = [
3700
			'isOpportunistic' => true,
3701
			'rootJobTimestamp' => $parserOutput->getCacheTime()
3702
		];
3703
3704
		if ( $this->mTitle->areRestrictionsCascading() ) {
3705
			// If the page is cascade protecting, the links should really be up-to-date
3706
			JobQueueGroup::singleton()->lazyPush(
3707
				RefreshLinksJob::newPrioritized( $this->mTitle, $params )
3708
			);
3709
		} elseif ( !$config->get( 'MiserMode' ) && $parserOutput->hasDynamicContent() ) {
3710
			// Assume the output contains "dynamic" time/random based magic words.
3711
			// Only update pages that expired due to dynamic content and NOT due to edits
3712
			// to referenced templates/files. When the cache expires due to dynamic content,
3713
			// page_touched is unchanged. We want to avoid triggering redundant jobs due to
3714
			// views of pages that were just purged via HTMLCacheUpdateJob. In that case, the
3715
			// template/file edit already triggered recursive RefreshLinksJob jobs.
3716
			if ( $this->getLinksTimestamp() > $this->getTouched() ) {
3717
				// If a page is uncacheable, do not keep spamming a job for it.
3718
				// Although it would be de-duplicated, it would still waste I/O.
3719
				$cache = ObjectCache::getLocalClusterInstance();
3720
				$key = $cache->makeKey( 'dynamic-linksupdate', 'last', $this->getId() );
3721
				$ttl = max( $parserOutput->getCacheExpiry(), 3600 );
3722
				if ( $cache->add( $key, time(), $ttl ) ) {
3723
					JobQueueGroup::singleton()->lazyPush(
3724
						RefreshLinksJob::newDynamic( $this->mTitle, $params )
3725
					);
3726
				}
3727
			}
3728
		}
3729
	}
3730
3731
	/**
3732
	 * Returns a list of updates to be performed when this page is deleted. The
3733
	 * updates should remove any information about this page from secondary data
3734
	 * stores such as links tables.
3735
	 *
3736
	 * @param Content|null $content Optional Content object for determining the
3737
	 *   necessary updates.
3738
	 * @return DeferrableUpdate[]
3739
	 */
3740
	public function getDeletionUpdates( Content $content = null ) {
3741
		if ( !$content ) {
3742
			// load content object, which may be used to determine the necessary updates.
3743
			// XXX: the content may not be needed to determine the updates.
3744
			try {
3745
				$content = $this->getContent( Revision::RAW );
3746
			} catch ( Exception $ex ) {
3747
				// If we can't load the content, something is wrong. Perhaps that's why
3748
				// the user is trying to delete the page, so let's not fail in that case.
3749
				// Note that doDeleteArticleReal() will already have logged an issue with
3750
				// loading the content.
3751
			}
3752
		}
3753
3754
		if ( !$content ) {
3755
			$updates = [];
3756
		} else {
3757
			$updates = $content->getDeletionUpdates( $this );
3758
		}
3759
3760
		Hooks::run( 'WikiPageDeletionUpdates', [ $this, $content, &$updates ] );
3761
		return $updates;
3762
	}
3763
3764
	/**
3765
	 * Whether this content displayed on this page
3766
	 * comes from the local database
3767
	 *
3768
	 * @since 1.28
3769
	 * @return bool
3770
	 */
3771
	public function isLocal() {
3772
		return true;
3773
	}
3774
}
3775