Completed
Branch master (e9bc15)
by
unknown
32:19
created

WikiPage::doQuickEditContent()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 23
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 16
nc 1
nop 5
dl 0
loc 23
rs 9.0856
c 0
b 0
f 0
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 $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 );
0 ignored issues
show
Bug introduced by
It seems like wfGetDB($index) can be null; however, pageDataFromTitle() 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...
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 );
0 ignored issues
show
Bug introduced by
It seems like wfGetDB($index) can be null; however, pageDataFromTitle() 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...
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;
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 );
0 ignored issues
show
Bug introduced by
It seems like $dbr defined by wfGetDB(DB_REPLICA) on line 632 can be null; however, Revision::newKnownCurrent() 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...
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
		wfDeprecated( __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() )}";
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 );
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 );
0 ignored issues
show
Bug introduced by
It seems like $dbr defined by wfGetDB(DB_REPLICA) on line 1437 can be null; however, Revision::loadFromTimestamp() 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...
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 );
0 ignored issues
show
Bug introduced by
It seems like $dbw defined by wfGetDB(DB_MASTER) on line 1446 can be null; however, Revision::loadFromTimestamp() 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...
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
		wfDeprecated( __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 );
0 ignored issues
show
Bug introduced by
It seems like $dbw defined by wfGetDB(DB_MASTER) on line 1799 can be null; however, Revision::insertOn() 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...
1828
			// Update page_latest and friends to reflect the new revision
1829
			if ( !$this->updateRevisionOn( $dbw, $revision, null, $meta['oldIsRedirect'] ) ) {
0 ignored issues
show
Bug introduced by
It seems like $dbw defined by wfGetDB(DB_MASTER) on line 1799 can be null; however, WikiPage::updateRevisionOn() 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...
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,
0 ignored issues
show
Bug introduced by
It seems like $dbw defined by wfGetDB(DB_MASTER) on line 1799 can be null; however, AtomicSectionUpdate::__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...
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 );
0 ignored issues
show
Bug introduced by
It seems like $dbw defined by wfGetDB(DB_MASTER) on line 1943 can be null; however, WikiPage::insertOn() 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...
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 );
0 ignored issues
show
Bug introduced by
It seems like $dbw defined by wfGetDB(DB_MASTER) on line 1943 can be null; however, Revision::insertOn() 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...
1977
		// Update the page record with revision data
1978
		if ( !$this->updateRevisionOn( $dbw, $revision, 0 ) ) {
0 ignored issues
show
Bug introduced by
It seems like $dbw defined by wfGetDB(DB_MASTER) on line 1943 can be null; however, WikiPage::updateRevisionOn() 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...
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,
0 ignored issues
show
Bug introduced by
It seems like $dbw defined by wfGetDB(DB_MASTER) on line 1943 can be null; however, AtomicSectionUpdate::__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...
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
	 * Update the article's restriction field, and leave a log entry.
2401
	 * This works for protection both existing and non-existing pages.
2402
	 *
2403
	 * @param array $limit Set of restriction keys
2404
	 * @param array $expiry Per restriction type expiration
2405
	 * @param int &$cascade Set to false if cascading protection isn't allowed.
2406
	 * @param string $reason
2407
	 * @param User $user The user updating the restrictions
2408
	 * @param string|string[] $tags Change tags to add to the pages and protection log entries
2409
	 *   ($user should be able to add the specified tags before this is called)
2410
	 * @return Status Status object; if action is taken, $status->value is the log_id of the
2411
	 *   protection log entry.
2412
	 */
2413
	public function doUpdateRestrictions( array $limit, array $expiry,
2414
		&$cascade, $reason, User $user, $tags = null
2415
	) {
2416
		global $wgCascadingRestrictionLevels, $wgContLang;
2417
2418
		if ( wfReadOnly() ) {
2419
			return Status::newFatal( 'readonlytext', wfReadOnlyReason() );
2420
		}
2421
2422
		$this->loadPageData( 'fromdbmaster' );
2423
		$restrictionTypes = $this->mTitle->getRestrictionTypes();
2424
		$id = $this->getId();
2425
2426
		if ( !$cascade ) {
2427
			$cascade = false;
2428
		}
2429
2430
		// Take this opportunity to purge out expired restrictions
2431
		Title::purgeExpiredRestrictions();
2432
2433
		// @todo FIXME: Same limitations as described in ProtectionForm.php (line 37);
2434
		// we expect a single selection, but the schema allows otherwise.
2435
		$isProtected = false;
2436
		$protect = false;
2437
		$changed = false;
2438
2439
		$dbw = wfGetDB( DB_MASTER );
2440
2441
		foreach ( $restrictionTypes as $action ) {
2442
			if ( !isset( $expiry[$action] ) || $expiry[$action] === $dbw->getInfinity() ) {
2443
				$expiry[$action] = 'infinity';
2444
			}
2445
			if ( !isset( $limit[$action] ) ) {
2446
				$limit[$action] = '';
2447
			} elseif ( $limit[$action] != '' ) {
2448
				$protect = true;
2449
			}
2450
2451
			// Get current restrictions on $action
2452
			$current = implode( '', $this->mTitle->getRestrictions( $action ) );
2453
			if ( $current != '' ) {
2454
				$isProtected = true;
2455
			}
2456
2457
			if ( $limit[$action] != $current ) {
2458
				$changed = true;
2459
			} elseif ( $limit[$action] != '' ) {
2460
				// Only check expiry change if the action is actually being
2461
				// protected, since expiry does nothing on an not-protected
2462
				// action.
2463
				if ( $this->mTitle->getRestrictionExpiry( $action ) != $expiry[$action] ) {
2464
					$changed = true;
2465
				}
2466
			}
2467
		}
2468
2469
		if ( !$changed && $protect && $this->mTitle->areRestrictionsCascading() != $cascade ) {
2470
			$changed = true;
2471
		}
2472
2473
		// If nothing has changed, do nothing
2474
		if ( !$changed ) {
2475
			return Status::newGood();
2476
		}
2477
2478
		if ( !$protect ) { // No protection at all means unprotection
2479
			$revCommentMsg = 'unprotectedarticle';
2480
			$logAction = 'unprotect';
2481
		} elseif ( $isProtected ) {
2482
			$revCommentMsg = 'modifiedarticleprotection';
2483
			$logAction = 'modify';
2484
		} else {
2485
			$revCommentMsg = 'protectedarticle';
2486
			$logAction = 'protect';
2487
		}
2488
2489
		// Truncate for whole multibyte characters
2490
		$reason = $wgContLang->truncate( $reason, 255 );
2491
2492
		$logRelationsValues = [];
2493
		$logRelationsField = null;
2494
		$logParamsDetails = [];
2495
2496
		// Null revision (used for change tag insertion)
2497
		$nullRevision = null;
2498
2499
		if ( $id ) { // Protection of existing page
2500
			if ( !Hooks::run( 'ArticleProtect', [ &$this, &$user, $limit, $reason ] ) ) {
2501
				return Status::newGood();
2502
			}
2503
2504
			// Only certain restrictions can cascade...
2505
			$editrestriction = isset( $limit['edit'] )
2506
				? [ $limit['edit'] ]
2507
				: $this->mTitle->getRestrictions( 'edit' );
2508
			foreach ( array_keys( $editrestriction, 'sysop' ) as $key ) {
2509
				$editrestriction[$key] = 'editprotected'; // backwards compatibility
2510
			}
2511
			foreach ( array_keys( $editrestriction, 'autoconfirmed' ) as $key ) {
2512
				$editrestriction[$key] = 'editsemiprotected'; // backwards compatibility
2513
			}
2514
2515
			$cascadingRestrictionLevels = $wgCascadingRestrictionLevels;
2516
			foreach ( array_keys( $cascadingRestrictionLevels, 'sysop' ) as $key ) {
2517
				$cascadingRestrictionLevels[$key] = 'editprotected'; // backwards compatibility
2518
			}
2519
			foreach ( array_keys( $cascadingRestrictionLevels, 'autoconfirmed' ) as $key ) {
2520
				$cascadingRestrictionLevels[$key] = 'editsemiprotected'; // backwards compatibility
2521
			}
2522
2523
			// The schema allows multiple restrictions
2524
			if ( !array_intersect( $editrestriction, $cascadingRestrictionLevels ) ) {
2525
				$cascade = false;
2526
			}
2527
2528
			// insert null revision to identify the page protection change as edit summary
2529
			$latest = $this->getLatest();
2530
			$nullRevision = $this->insertProtectNullRevision(
2531
				$revCommentMsg,
2532
				$limit,
2533
				$expiry,
2534
				$cascade,
2535
				$reason,
2536
				$user
2537
			);
2538
2539
			if ( $nullRevision === null ) {
2540
				return Status::newFatal( 'no-null-revision', $this->mTitle->getPrefixedText() );
2541
			}
2542
2543
			$logRelationsField = 'pr_id';
2544
2545
			// Update restrictions table
2546
			foreach ( $limit as $action => $restrictions ) {
2547
				$dbw->delete(
2548
					'page_restrictions',
2549
					[
2550
						'pr_page' => $id,
2551
						'pr_type' => $action
2552
					],
2553
					__METHOD__
2554
				);
2555
				if ( $restrictions != '' ) {
2556
					$cascadeValue = ( $cascade && $action == 'edit' ) ? 1 : 0;
2557
					$dbw->insert(
2558
						'page_restrictions',
2559
						[
2560
							'pr_id' => $dbw->nextSequenceValue( 'page_restrictions_pr_id_seq' ),
2561
							'pr_page' => $id,
2562
							'pr_type' => $action,
2563
							'pr_level' => $restrictions,
2564
							'pr_cascade' => $cascadeValue,
2565
							'pr_expiry' => $dbw->encodeExpiry( $expiry[$action] )
2566
						],
2567
						__METHOD__
2568
					);
2569
					$logRelationsValues[] = $dbw->insertId();
2570
					$logParamsDetails[] = [
2571
						'type' => $action,
2572
						'level' => $restrictions,
2573
						'expiry' => $expiry[$action],
2574
						'cascade' => (bool)$cascadeValue,
2575
					];
2576
				}
2577
			}
2578
2579
			// Clear out legacy restriction fields
2580
			$dbw->update(
2581
				'page',
2582
				[ 'page_restrictions' => '' ],
2583
				[ 'page_id' => $id ],
2584
				__METHOD__
2585
			);
2586
2587
			Hooks::run( 'NewRevisionFromEditComplete',
2588
				[ $this, $nullRevision, $latest, $user ] );
2589
			Hooks::run( 'ArticleProtectComplete', [ &$this, &$user, $limit, $reason ] );
2590
		} else { // Protection of non-existing page (also known as "title protection")
2591
			// Cascade protection is meaningless in this case
2592
			$cascade = false;
2593
2594
			if ( $limit['create'] != '' ) {
2595
				$dbw->replace( 'protected_titles',
2596
					[ [ 'pt_namespace', 'pt_title' ] ],
2597
					[
2598
						'pt_namespace' => $this->mTitle->getNamespace(),
2599
						'pt_title' => $this->mTitle->getDBkey(),
2600
						'pt_create_perm' => $limit['create'],
2601
						'pt_timestamp' => $dbw->timestamp(),
2602
						'pt_expiry' => $dbw->encodeExpiry( $expiry['create'] ),
2603
						'pt_user' => $user->getId(),
2604
						'pt_reason' => $reason,
2605
					], __METHOD__
2606
				);
2607
				$logParamsDetails[] = [
2608
					'type' => 'create',
2609
					'level' => $limit['create'],
2610
					'expiry' => $expiry['create'],
2611
				];
2612
			} else {
2613
				$dbw->delete( 'protected_titles',
2614
					[
2615
						'pt_namespace' => $this->mTitle->getNamespace(),
2616
						'pt_title' => $this->mTitle->getDBkey()
2617
					], __METHOD__
2618
				);
2619
			}
2620
		}
2621
2622
		$this->mTitle->flushRestrictions();
2623
		InfoAction::invalidateCache( $this->mTitle );
2624
2625
		if ( $logAction == 'unprotect' ) {
2626
			$params = [];
2627
		} else {
2628
			$protectDescriptionLog = $this->protectDescriptionLog( $limit, $expiry );
2629
			$params = [
2630
				'4::description' => $protectDescriptionLog, // parameter for IRC
2631
				'5:bool:cascade' => $cascade,
2632
				'details' => $logParamsDetails, // parameter for localize and api
2633
			];
2634
		}
2635
2636
		// Update the protection log
2637
		$logEntry = new ManualLogEntry( 'protect', $logAction );
2638
		$logEntry->setTarget( $this->mTitle );
2639
		$logEntry->setComment( $reason );
2640
		$logEntry->setPerformer( $user );
2641
		$logEntry->setParameters( $params );
2642
		if ( !is_null( $nullRevision ) ) {
2643
			$logEntry->setAssociatedRevId( $nullRevision->getId() );
2644
		}
2645
		$logEntry->setTags( $tags );
0 ignored issues
show
Bug introduced by
It seems like $tags defined by parameter $tags on line 2414 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...
2646
		if ( $logRelationsField !== null && count( $logRelationsValues ) ) {
2647
			$logEntry->setRelations( [ $logRelationsField => $logRelationsValues ] );
2648
		}
2649
		$logId = $logEntry->insert();
2650
		$logEntry->publish( $logId );
2651
2652
		return Status::newGood( $logId );
2653
	}
2654
2655
	/**
2656
	 * Insert a new null revision for this page.
2657
	 *
2658
	 * @param string $revCommentMsg Comment message key for the revision
2659
	 * @param array $limit Set of restriction keys
2660
	 * @param array $expiry Per restriction type expiration
2661
	 * @param int $cascade Set to false if cascading protection isn't allowed.
2662
	 * @param string $reason
2663
	 * @param User|null $user
2664
	 * @return Revision|null Null on error
2665
	 */
2666
	public function insertProtectNullRevision( $revCommentMsg, array $limit,
2667
		array $expiry, $cascade, $reason, $user = null
2668
	) {
2669
		global $wgContLang;
2670
		$dbw = wfGetDB( DB_MASTER );
2671
2672
		// Prepare a null revision to be added to the history
2673
		$editComment = $wgContLang->ucfirst(
2674
			wfMessage(
2675
				$revCommentMsg,
2676
				$this->mTitle->getPrefixedText()
2677
			)->inContentLanguage()->text()
2678
		);
2679
		if ( $reason ) {
2680
			$editComment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
2681
		}
2682
		$protectDescription = $this->protectDescription( $limit, $expiry );
2683
		if ( $protectDescription ) {
2684
			$editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2685
			$editComment .= wfMessage( 'parentheses' )->params( $protectDescription )
2686
				->inContentLanguage()->text();
2687
		}
2688
		if ( $cascade ) {
2689
			$editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2690
			$editComment .= wfMessage( 'brackets' )->params(
2691
				wfMessage( 'protect-summary-cascade' )->inContentLanguage()->text()
2692
			)->inContentLanguage()->text();
2693
		}
2694
2695
		$nullRev = Revision::newNullRevision( $dbw, $this->getId(), $editComment, true, $user );
0 ignored issues
show
Bug introduced by
It seems like $dbw defined by wfGetDB(DB_MASTER) on line 2670 can be null; however, Revision::newNullRevision() 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...
2696
		if ( $nullRev ) {
2697
			$nullRev->insertOn( $dbw );
0 ignored issues
show
Bug introduced by
It seems like $dbw defined by wfGetDB(DB_MASTER) on line 2670 can be null; however, Revision::insertOn() 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...
2698
2699
			// Update page record and touch page
2700
			$oldLatest = $nullRev->getParentId();
2701
			$this->updateRevisionOn( $dbw, $nullRev, $oldLatest );
0 ignored issues
show
Bug introduced by
It seems like $dbw defined by wfGetDB(DB_MASTER) on line 2670 can be null; however, WikiPage::updateRevisionOn() 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...
2702
		}
2703
2704
		return $nullRev;
2705
	}
2706
2707
	/**
2708
	 * @param string $expiry 14-char timestamp or "infinity", or false if the input was invalid
2709
	 * @return string
2710
	 */
2711
	protected function formatExpiry( $expiry ) {
2712
		global $wgContLang;
2713
2714
		if ( $expiry != 'infinity' ) {
2715
			return wfMessage(
2716
				'protect-expiring',
2717
				$wgContLang->timeanddate( $expiry, false, false ),
2718
				$wgContLang->date( $expiry, false, false ),
2719
				$wgContLang->time( $expiry, false, false )
2720
			)->inContentLanguage()->text();
2721
		} else {
2722
			return wfMessage( 'protect-expiry-indefinite' )
2723
				->inContentLanguage()->text();
2724
		}
2725
	}
2726
2727
	/**
2728
	 * Builds the description to serve as comment for the edit.
2729
	 *
2730
	 * @param array $limit Set of restriction keys
2731
	 * @param array $expiry Per restriction type expiration
2732
	 * @return string
2733
	 */
2734
	public function protectDescription( array $limit, array $expiry ) {
2735
		$protectDescription = '';
2736
2737
		foreach ( array_filter( $limit ) as $action => $restrictions ) {
2738
			# $action is one of $wgRestrictionTypes = [ 'create', 'edit', 'move', 'upload' ].
2739
			# All possible message keys are listed here for easier grepping:
2740
			# * restriction-create
2741
			# * restriction-edit
2742
			# * restriction-move
2743
			# * restriction-upload
2744
			$actionText = wfMessage( 'restriction-' . $action )->inContentLanguage()->text();
2745
			# $restrictions is one of $wgRestrictionLevels = [ '', 'autoconfirmed', 'sysop' ],
2746
			# with '' filtered out. All possible message keys are listed below:
2747
			# * protect-level-autoconfirmed
2748
			# * protect-level-sysop
2749
			$restrictionsText = wfMessage( 'protect-level-' . $restrictions )
2750
				->inContentLanguage()->text();
2751
2752
			$expiryText = $this->formatExpiry( $expiry[$action] );
2753
2754
			if ( $protectDescription !== '' ) {
2755
				$protectDescription .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2756
			}
2757
			$protectDescription .= wfMessage( 'protect-summary-desc' )
2758
				->params( $actionText, $restrictionsText, $expiryText )
2759
				->inContentLanguage()->text();
2760
		}
2761
2762
		return $protectDescription;
2763
	}
2764
2765
	/**
2766
	 * Builds the description to serve as comment for the log entry.
2767
	 *
2768
	 * Some bots may parse IRC lines, which are generated from log entries which contain plain
2769
	 * protect description text. Keep them in old format to avoid breaking compatibility.
2770
	 * TODO: Fix protection log to store structured description and format it on-the-fly.
2771
	 *
2772
	 * @param array $limit Set of restriction keys
2773
	 * @param array $expiry Per restriction type expiration
2774
	 * @return string
2775
	 */
2776
	public function protectDescriptionLog( array $limit, array $expiry ) {
2777
		global $wgContLang;
2778
2779
		$protectDescriptionLog = '';
2780
2781
		foreach ( array_filter( $limit ) as $action => $restrictions ) {
2782
			$expiryText = $this->formatExpiry( $expiry[$action] );
2783
			$protectDescriptionLog .= $wgContLang->getDirMark() .
2784
				"[$action=$restrictions] ($expiryText)";
2785
		}
2786
2787
		return trim( $protectDescriptionLog );
2788
	}
2789
2790
	/**
2791
	 * Take an array of page restrictions and flatten it to a string
2792
	 * suitable for insertion into the page_restrictions field.
2793
	 *
2794
	 * @param string[] $limit
2795
	 *
2796
	 * @throws MWException
2797
	 * @return string
2798
	 */
2799
	protected static function flattenRestrictions( $limit ) {
2800
		if ( !is_array( $limit ) ) {
2801
			throw new MWException( __METHOD__ . ' given non-array restriction set' );
2802
		}
2803
2804
		$bits = [];
2805
		ksort( $limit );
2806
2807
		foreach ( array_filter( $limit ) as $action => $restrictions ) {
2808
			$bits[] = "$action=$restrictions";
2809
		}
2810
2811
		return implode( ':', $bits );
2812
	}
2813
2814
	/**
2815
	 * Same as doDeleteArticleReal(), but returns a simple boolean. This is kept around for
2816
	 * backwards compatibility, if you care about error reporting you should use
2817
	 * doDeleteArticleReal() instead.
2818
	 *
2819
	 * Deletes the article with database consistency, writes logs, purges caches
2820
	 *
2821
	 * @param string $reason Delete reason for deletion log
2822
	 * @param bool $suppress Suppress all revisions and log the deletion in
2823
	 *        the suppression log instead of the deletion log
2824
	 * @param int $u1 Unused
2825
	 * @param bool $u2 Unused
2826
	 * @param array|string &$error Array of errors to append to
2827
	 * @param User $user The deleting user
2828
	 * @return bool True if successful
2829
	 */
2830
	public function doDeleteArticle(
2831
		$reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null
2832
	) {
2833
		$status = $this->doDeleteArticleReal( $reason, $suppress, $u1, $u2, $error, $user );
2834
		return $status->isGood();
2835
	}
2836
2837
	/**
2838
	 * Back-end article deletion
2839
	 * Deletes the article with database consistency, writes logs, purges caches
2840
	 *
2841
	 * @since 1.19
2842
	 *
2843
	 * @param string $reason Delete reason for deletion log
2844
	 * @param bool $suppress Suppress all revisions and log the deletion in
2845
	 *   the suppression log instead of the deletion log
2846
	 * @param int $u1 Unused
2847
	 * @param bool $u2 Unused
2848
	 * @param array|string &$error Array of errors to append to
2849
	 * @param User $user The deleting user
2850
	 * @param array $tags Tags to apply to the deletion action
2851
	 * @return Status Status object; if successful, $status->value is the log_id of the
2852
	 *   deletion log entry. If the page couldn't be deleted because it wasn't
2853
	 *   found, $status is a non-fatal 'cannotdelete' error
2854
	 */
2855
	public function doDeleteArticleReal(
2856
		$reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null,
2857
		$tags = []
2858
	) {
2859
		global $wgUser, $wgContentHandlerUseDB;
2860
2861
		wfDebug( __METHOD__ . "\n" );
2862
2863
		$status = Status::newGood();
2864
2865
		if ( $this->mTitle->getDBkey() === '' ) {
2866
			$status->error( 'cannotdelete',
2867
				wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
2868
			return $status;
2869
		}
2870
2871
		$user = is_null( $user ) ? $wgUser : $user;
2872
		if ( !Hooks::run( 'ArticleDelete',
2873
			[ &$this, &$user, &$reason, &$error, &$status, $suppress ]
2874
		) ) {
2875
			if ( $status->isOK() ) {
2876
				// Hook aborted but didn't set a fatal status
2877
				$status->fatal( 'delete-hook-aborted' );
2878
			}
2879
			return $status;
2880
		}
2881
2882
		$dbw = wfGetDB( DB_MASTER );
2883
		$dbw->startAtomic( __METHOD__ );
2884
2885
		$this->loadPageData( self::READ_LATEST );
2886
		$id = $this->getId();
2887
		// T98706: lock the page from various other updates but avoid using
2888
		// WikiPage::READ_LOCKING as that will carry over the FOR UPDATE to
2889
		// the revisions queries (which also JOIN on user). Only lock the page
2890
		// row and CAS check on page_latest to see if the trx snapshot matches.
2891
		$lockedLatest = $this->lockAndGetLatest();
2892
		if ( $id == 0 || $this->getLatest() != $lockedLatest ) {
2893
			$dbw->endAtomic( __METHOD__ );
2894
			// Page not there or trx snapshot is stale
2895
			$status->error( 'cannotdelete',
2896
				wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
2897
			return $status;
2898
		}
2899
2900
		// Given the lock above, we can be confident in the title and page ID values
2901
		$namespace = $this->getTitle()->getNamespace();
2902
		$dbKey = $this->getTitle()->getDBkey();
2903
2904
		// At this point we are now comitted to returning an OK
2905
		// status unless some DB query error or other exception comes up.
2906
		// This way callers don't have to call rollback() if $status is bad
2907
		// unless they actually try to catch exceptions (which is rare).
2908
2909
		// we need to remember the old content so we can use it to generate all deletion updates.
2910
		try {
2911
			$content = $this->getContent( Revision::RAW );
2912
		} catch ( Exception $ex ) {
2913
			wfLogWarning( __METHOD__ . ': failed to load content during deletion! '
2914
				. $ex->getMessage() );
2915
2916
			$content = null;
2917
		}
2918
2919
		// Bitfields to further suppress the content
2920
		if ( $suppress ) {
2921
			$bitfield = 0;
2922
			// This should be 15...
2923
			$bitfield |= Revision::DELETED_TEXT;
2924
			$bitfield |= Revision::DELETED_COMMENT;
2925
			$bitfield |= Revision::DELETED_USER;
2926
			$bitfield |= Revision::DELETED_RESTRICTED;
2927
			$deletionFields = [ $dbw->addQuotes( $bitfield ) . ' AS deleted' ];
2928
		} else {
2929
			$deletionFields = [ 'rev_deleted AS deleted' ];
2930
		}
2931
2932
		// For now, shunt the revision data into the archive table.
2933
		// Text is *not* removed from the text table; bulk storage
2934
		// is left intact to avoid breaking block-compression or
2935
		// immutable storage schemes.
2936
		// In the future, we may keep revisions and mark them with
2937
		// the rev_deleted field, which is reserved for this purpose.
2938
2939
		// Get all of the page revisions
2940
		$fields = array_diff( Revision::selectFields(), [ 'rev_deleted' ] );
2941
		$res = $dbw->select(
2942
			'revision',
2943
			array_merge( $fields, $deletionFields ),
2944
			[ 'rev_page' => $id ],
2945
			__METHOD__,
2946
			'FOR UPDATE'
2947
		);
2948
		// Build their equivalent archive rows
2949
		$rowsInsert = [];
2950
		foreach ( $res as $row ) {
2951
			$rowInsert = [
2952
				'ar_namespace'  => $namespace,
2953
				'ar_title'      => $dbKey,
2954
				'ar_comment'    => $row->rev_comment,
2955
				'ar_user'       => $row->rev_user,
2956
				'ar_user_text'  => $row->rev_user_text,
2957
				'ar_timestamp'  => $row->rev_timestamp,
2958
				'ar_minor_edit' => $row->rev_minor_edit,
2959
				'ar_rev_id'     => $row->rev_id,
2960
				'ar_parent_id'  => $row->rev_parent_id,
2961
				'ar_text_id'    => $row->rev_text_id,
2962
				'ar_text'       => '',
2963
				'ar_flags'      => '',
2964
				'ar_len'        => $row->rev_len,
2965
				'ar_page_id'    => $id,
2966
				'ar_deleted'    => $row->deleted,
2967
				'ar_sha1'       => $row->rev_sha1,
2968
			];
2969
			if ( $wgContentHandlerUseDB ) {
2970
				$rowInsert['ar_content_model'] = $row->rev_content_model;
2971
				$rowInsert['ar_content_format'] = $row->rev_content_format;
2972
			}
2973
			$rowsInsert[] = $rowInsert;
2974
		}
2975
		// Copy them into the archive table
2976
		$dbw->insert( 'archive', $rowsInsert, __METHOD__ );
2977
		// Save this so we can pass it to the ArticleDeleteComplete hook.
2978
		$archivedRevisionCount = $dbw->affectedRows();
2979
2980
		// Clone the title and wikiPage, so we have the information we need when
2981
		// we log and run the ArticleDeleteComplete hook.
2982
		$logTitle = clone $this->mTitle;
2983
		$wikiPageBeforeDelete = clone $this;
2984
2985
		// Now that it's safely backed up, delete it
2986
		$dbw->delete( 'page', [ 'page_id' => $id ], __METHOD__ );
2987
		$dbw->delete( 'revision', [ 'rev_page' => $id ], __METHOD__ );
2988
2989
		// Log the deletion, if the page was suppressed, put it in the suppression log instead
2990
		$logtype = $suppress ? 'suppress' : 'delete';
2991
2992
		$logEntry = new ManualLogEntry( $logtype, 'delete' );
2993
		$logEntry->setPerformer( $user );
2994
		$logEntry->setTarget( $logTitle );
2995
		$logEntry->setComment( $reason );
2996
		$logEntry->setTags( $tags );
2997
		$logid = $logEntry->insert();
2998
2999
		$dbw->onTransactionPreCommitOrIdle(
3000
			function () use ( $dbw, $logEntry, $logid ) {
3001
				// Bug 56776: avoid deadlocks (especially from FileDeleteForm)
3002
				$logEntry->publish( $logid );
3003
			},
3004
			__METHOD__
3005
		);
3006
3007
		$dbw->endAtomic( __METHOD__ );
3008
3009
		$this->doDeleteUpdates( $id, $content );
3010
3011
		Hooks::run( 'ArticleDeleteComplete', [
3012
			&$wikiPageBeforeDelete,
3013
			&$user,
3014
			$reason,
3015
			$id,
3016
			$content,
3017
			$logEntry,
3018
			$archivedRevisionCount
3019
		] );
3020
		$status->value = $logid;
3021
3022
		// Show log excerpt on 404 pages rather than just a link
3023
		$cache = ObjectCache::getMainStashInstance();
3024
		$key = wfMemcKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
3025
		$cache->set( $key, 1, $cache::TTL_DAY );
3026
3027
		return $status;
3028
	}
3029
3030
	/**
3031
	 * Lock the page row for this title+id and return page_latest (or 0)
3032
	 *
3033
	 * @return integer Returns 0 if no row was found with this title+id
3034
	 * @since 1.27
3035
	 */
3036
	public function lockAndGetLatest() {
3037
		return (int)wfGetDB( DB_MASTER )->selectField(
3038
			'page',
3039
			'page_latest',
3040
			[
3041
				'page_id' => $this->getId(),
3042
				// Typically page_id is enough, but some code might try to do
3043
				// updates assuming the title is the same, so verify that
3044
				'page_namespace' => $this->getTitle()->getNamespace(),
3045
				'page_title' => $this->getTitle()->getDBkey()
3046
			],
3047
			__METHOD__,
3048
			[ 'FOR UPDATE' ]
3049
		);
3050
	}
3051
3052
	/**
3053
	 * Do some database updates after deletion
3054
	 *
3055
	 * @param int $id The page_id value of the page being deleted
3056
	 * @param Content $content Optional page content to be used when determining
3057
	 *   the required updates. This may be needed because $this->getContent()
3058
	 *   may already return null when the page proper was deleted.
3059
	 */
3060
	public function doDeleteUpdates( $id, Content $content = null ) {
3061
		try {
3062
			$countable = $this->isCountable();
3063
		} catch ( Exception $ex ) {
3064
			// fallback for deleting broken pages for which we cannot load the content for
3065
			// some reason. Note that doDeleteArticleReal() already logged this problem.
3066
			$countable = false;
3067
		}
3068
3069
		// Update site status
3070
		DeferredUpdates::addUpdate( new SiteStatsUpdate( 0, 1, - (int)$countable, -1 ) );
3071
3072
		// Delete pagelinks, update secondary indexes, etc
3073
		$updates = $this->getDeletionUpdates( $content );
3074
		foreach ( $updates as $update ) {
3075
			DeferredUpdates::addUpdate( $update );
3076
		}
3077
3078
		// Reparse any pages transcluding this page
3079
		LinksUpdate::queueRecursiveJobsForTable( $this->mTitle, 'templatelinks' );
3080
3081
		// Reparse any pages including this image
3082
		if ( $this->mTitle->getNamespace() == NS_FILE ) {
3083
			LinksUpdate::queueRecursiveJobsForTable( $this->mTitle, 'imagelinks' );
3084
		}
3085
3086
		// Clear caches
3087
		WikiPage::onArticleDelete( $this->mTitle );
3088
3089
		// Reset this object and the Title object
3090
		$this->loadFromRow( false, self::READ_LATEST );
3091
3092
		// Search engine
3093
		DeferredUpdates::addUpdate( new SearchUpdate( $id, $this->mTitle ) );
3094
	}
3095
3096
	/**
3097
	 * Roll back the most recent consecutive set of edits to a page
3098
	 * from the same user; fails if there are no eligible edits to
3099
	 * roll back to, e.g. user is the sole contributor. This function
3100
	 * performs permissions checks on $user, then calls commitRollback()
3101
	 * to do the dirty work
3102
	 *
3103
	 * @todo Separate the business/permission stuff out from backend code
3104
	 * @todo Remove $token parameter. Already verified by RollbackAction and ApiRollback.
3105
	 *
3106
	 * @param string $fromP Name of the user whose edits to rollback.
3107
	 * @param string $summary Custom summary. Set to default summary if empty.
3108
	 * @param string $token Rollback token.
3109
	 * @param bool $bot If true, mark all reverted edits as bot.
3110
	 *
3111
	 * @param array $resultDetails Array contains result-specific array of additional values
3112
	 *    'alreadyrolled' : 'current' (rev)
3113
	 *    success        : 'summary' (str), 'current' (rev), 'target' (rev)
3114
	 *
3115
	 * @param User $user The user performing the rollback
3116
	 * @param array|null $tags Change tags to apply to the rollback
3117
	 * Callers are responsible for permission checks
3118
	 * (with ChangeTags::canAddTagsAccompanyingChange)
3119
	 *
3120
	 * @return array Array of errors, each error formatted as
3121
	 *   array(messagekey, param1, param2, ...).
3122
	 * On success, the array is empty.  This array can also be passed to
3123
	 * OutputPage::showPermissionsErrorPage().
3124
	 */
3125
	public function doRollback(
3126
		$fromP, $summary, $token, $bot, &$resultDetails, User $user, $tags = null
3127
	) {
3128
		$resultDetails = null;
3129
3130
		// Check permissions
3131
		$editErrors = $this->mTitle->getUserPermissionsErrors( 'edit', $user );
3132
		$rollbackErrors = $this->mTitle->getUserPermissionsErrors( 'rollback', $user );
3133
		$errors = array_merge( $editErrors, wfArrayDiff2( $rollbackErrors, $editErrors ) );
3134
3135
		if ( !$user->matchEditToken( $token, 'rollback' ) ) {
3136
			$errors[] = [ 'sessionfailure' ];
3137
		}
3138
3139
		if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) {
3140
			$errors[] = [ 'actionthrottledtext' ];
3141
		}
3142
3143
		// If there were errors, bail out now
3144
		if ( !empty( $errors ) ) {
3145
			return $errors;
3146
		}
3147
3148
		return $this->commitRollback( $fromP, $summary, $bot, $resultDetails, $user, $tags );
3149
	}
3150
3151
	/**
3152
	 * Backend implementation of doRollback(), please refer there for parameter
3153
	 * and return value documentation
3154
	 *
3155
	 * NOTE: This function does NOT check ANY permissions, it just commits the
3156
	 * rollback to the DB. Therefore, you should only call this function direct-
3157
	 * ly if you want to use custom permissions checks. If you don't, use
3158
	 * doRollback() instead.
3159
	 * @param string $fromP Name of the user whose edits to rollback.
3160
	 * @param string $summary Custom summary. Set to default summary if empty.
3161
	 * @param bool $bot If true, mark all reverted edits as bot.
3162
	 *
3163
	 * @param array $resultDetails Contains result-specific array of additional values
3164
	 * @param User $guser The user performing the rollback
3165
	 * @param array|null $tags Change tags to apply to the rollback
3166
	 * Callers are responsible for permission checks
3167
	 * (with ChangeTags::canAddTagsAccompanyingChange)
3168
	 *
3169
	 * @return array
3170
	 */
3171
	public function commitRollback( $fromP, $summary, $bot,
3172
		&$resultDetails, User $guser, $tags = null
3173
	) {
3174
		global $wgUseRCPatrol, $wgContLang;
3175
3176
		$dbw = wfGetDB( DB_MASTER );
3177
3178
		if ( wfReadOnly() ) {
3179
			return [ [ 'readonlytext' ] ];
3180
		}
3181
3182
		// Get the last editor
3183
		$current = $this->getRevision();
3184
		if ( is_null( $current ) ) {
3185
			// Something wrong... no page?
3186
			return [ [ 'notanarticle' ] ];
3187
		}
3188
3189
		$from = str_replace( '_', ' ', $fromP );
3190
		// User name given should match up with the top revision.
3191
		// If the user was deleted then $from should be empty.
3192 View Code Duplication
		if ( $from != $current->getUserText() ) {
3193
			$resultDetails = [ 'current' => $current ];
3194
			return [ [ 'alreadyrolled',
3195
				htmlspecialchars( $this->mTitle->getPrefixedText() ),
3196
				htmlspecialchars( $fromP ),
3197
				htmlspecialchars( $current->getUserText() )
3198
			] ];
3199
		}
3200
3201
		// Get the last edit not by this person...
3202
		// Note: these may not be public values
3203
		$user = intval( $current->getUser( Revision::RAW ) );
3204
		$user_text = $dbw->addQuotes( $current->getUserText( Revision::RAW ) );
3205
		$s = $dbw->selectRow( 'revision',
3206
			[ 'rev_id', 'rev_timestamp', 'rev_deleted' ],
3207
			[ 'rev_page' => $current->getPage(),
3208
				"rev_user != {$user} OR rev_user_text != {$user_text}"
3209
			], __METHOD__,
3210
			[ 'USE INDEX' => 'page_timestamp',
3211
				'ORDER BY' => 'rev_timestamp DESC' ]
3212
			);
3213
		if ( $s === false ) {
3214
			// No one else ever edited this page
3215
			return [ [ 'cantrollback' ] ];
3216
		} elseif ( $s->rev_deleted & Revision::DELETED_TEXT
3217
			|| $s->rev_deleted & Revision::DELETED_USER
3218
		) {
3219
			// Only admins can see this text
3220
			return [ [ 'notvisiblerev' ] ];
3221
		}
3222
3223
		// Generate the edit summary if necessary
3224
		$target = Revision::newFromId( $s->rev_id, Revision::READ_LATEST );
3225
		if ( empty( $summary ) ) {
3226
			if ( $from == '' ) { // no public user name
3227
				$summary = wfMessage( 'revertpage-nouser' );
3228
			} else {
3229
				$summary = wfMessage( 'revertpage' );
3230
			}
3231
		}
3232
3233
		// Allow the custom summary to use the same args as the default message
3234
		$args = [
3235
			$target->getUserText(), $from, $s->rev_id,
3236
			$wgContLang->timeanddate( wfTimestamp( TS_MW, $s->rev_timestamp ) ),
3237
			$current->getId(), $wgContLang->timeanddate( $current->getTimestamp() )
3238
		];
3239
		if ( $summary instanceof Message ) {
3240
			$summary = $summary->params( $args )->inContentLanguage()->text();
3241
		} else {
3242
			$summary = wfMsgReplaceArgs( $summary, $args );
3243
		}
3244
3245
		// Trim spaces on user supplied text
3246
		$summary = trim( $summary );
3247
3248
		// Truncate for whole multibyte characters.
3249
		$summary = $wgContLang->truncate( $summary, 255 );
3250
3251
		// Save
3252
		$flags = EDIT_UPDATE | EDIT_INTERNAL;
3253
3254
		if ( $guser->isAllowed( 'minoredit' ) ) {
3255
			$flags |= EDIT_MINOR;
3256
		}
3257
3258
		if ( $bot && ( $guser->isAllowedAny( 'markbotedits', 'bot' ) ) ) {
3259
			$flags |= EDIT_FORCE_BOT;
3260
		}
3261
3262
		$targetContent = $target->getContent();
3263
		$changingContentModel = $targetContent->getModel() !== $current->getContentModel();
3264
3265
		// Actually store the edit
3266
		$status = $this->doEditContent(
3267
			$targetContent,
3268
			$summary,
3269
			$flags,
3270
			$target->getId(),
3271
			$guser,
3272
			null,
3273
			$tags
3274
		);
3275
3276
		// Set patrolling and bot flag on the edits, which gets rollbacked.
3277
		// This is done even on edit failure to have patrolling in that case (bug 62157).
3278
		$set = [];
3279
		if ( $bot && $guser->isAllowed( 'markbotedits' ) ) {
3280
			// Mark all reverted edits as bot
3281
			$set['rc_bot'] = 1;
3282
		}
3283
3284
		if ( $wgUseRCPatrol ) {
3285
			// Mark all reverted edits as patrolled
3286
			$set['rc_patrolled'] = 1;
3287
		}
3288
3289
		if ( count( $set ) ) {
3290
			$dbw->update( 'recentchanges', $set,
3291
				[ /* WHERE */
3292
					'rc_cur_id' => $current->getPage(),
3293
					'rc_user_text' => $current->getUserText(),
3294
					'rc_timestamp > ' . $dbw->addQuotes( $s->rev_timestamp ),
3295
				],
3296
				__METHOD__
3297
			);
3298
		}
3299
3300
		if ( !$status->isOK() ) {
3301
			return $status->getErrorsArray();
3302
		}
3303
3304
		// raise error, when the edit is an edit without a new version
3305
		$statusRev = isset( $status->value['revision'] )
3306
			? $status->value['revision']
3307
			: null;
3308 View Code Duplication
		if ( !( $statusRev instanceof Revision ) ) {
3309
			$resultDetails = [ 'current' => $current ];
3310
			return [ [ 'alreadyrolled',
3311
					htmlspecialchars( $this->mTitle->getPrefixedText() ),
3312
					htmlspecialchars( $fromP ),
3313
					htmlspecialchars( $current->getUserText() )
3314
			] ];
3315
		}
3316
3317
		if ( $changingContentModel ) {
3318
			// If the content model changed during the rollback,
3319
			// make sure it gets logged to Special:Log/contentmodel
3320
			$log = new ManualLogEntry( 'contentmodel', 'change' );
3321
			$log->setPerformer( $guser );
3322
			$log->setTarget( $this->mTitle );
3323
			$log->setComment( $summary );
3324
			$log->setParameters( [
3325
				'4::oldmodel' => $current->getContentModel(),
3326
				'5::newmodel' => $targetContent->getModel(),
3327
			] );
3328
3329
			$logId = $log->insert( $dbw );
3330
			$log->publish( $logId );
3331
		}
3332
3333
		$revId = $statusRev->getId();
3334
3335
		Hooks::run( 'ArticleRollbackComplete', [ $this, $guser, $target, $current ] );
3336
3337
		$resultDetails = [
3338
			'summary' => $summary,
3339
			'current' => $current,
3340
			'target' => $target,
3341
			'newid' => $revId
3342
		];
3343
3344
		return [];
3345
	}
3346
3347
	/**
3348
	 * The onArticle*() functions are supposed to be a kind of hooks
3349
	 * which should be called whenever any of the specified actions
3350
	 * are done.
3351
	 *
3352
	 * This is a good place to put code to clear caches, for instance.
3353
	 *
3354
	 * This is called on page move and undelete, as well as edit
3355
	 *
3356
	 * @param Title $title
3357
	 */
3358
	public static function onArticleCreate( Title $title ) {
3359
		// Update existence markers on article/talk tabs...
3360
		$other = $title->getOtherPage();
3361
3362
		$other->purgeSquid();
3363
3364
		$title->touchLinks();
3365
		$title->purgeSquid();
3366
		$title->deleteTitleProtection();
3367
3368
		MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
3369
3370
		if ( $title->getNamespace() == NS_CATEGORY ) {
3371
			// Load the Category object, which will schedule a job to create
3372
			// the category table row if necessary. Checking a replica DB is ok
3373
			// here, in the worst case it'll run an unnecessary recount job on
3374
			// a category that probably doesn't have many members.
3375
			Category::newFromTitle( $title )->getID();
3376
		}
3377
	}
3378
3379
	/**
3380
	 * Clears caches when article is deleted
3381
	 *
3382
	 * @param Title $title
3383
	 */
3384
	public static function onArticleDelete( Title $title ) {
3385
		global $wgContLang;
3386
3387
		// Update existence markers on article/talk tabs...
3388
		$other = $title->getOtherPage();
3389
3390
		$other->purgeSquid();
3391
3392
		$title->touchLinks();
3393
		$title->purgeSquid();
3394
3395
		MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
3396
3397
		// File cache
3398
		HTMLFileCache::clearFileCache( $title );
3399
		InfoAction::invalidateCache( $title );
3400
3401
		// Messages
3402
		if ( $title->getNamespace() == NS_MEDIAWIKI ) {
3403
			MessageCache::singleton()->replace( $title->getDBkey(), false );
3404
3405
			if ( $wgContLang->hasVariants() ) {
3406
				$wgContLang->updateConversionTable( $title );
3407
			}
3408
		}
3409
3410
		// Images
3411
		if ( $title->getNamespace() == NS_FILE ) {
3412
			DeferredUpdates::addUpdate( new HTMLCacheUpdate( $title, 'imagelinks' ) );
3413
		}
3414
3415
		// User talk pages
3416
		if ( $title->getNamespace() == NS_USER_TALK ) {
3417
			$user = User::newFromName( $title->getText(), false );
3418
			if ( $user ) {
3419
				$user->setNewtalk( false );
3420
			}
3421
		}
3422
3423
		// Image redirects
3424
		RepoGroup::singleton()->getLocalRepo()->invalidateImageRedirect( $title );
3425
	}
3426
3427
	/**
3428
	 * Purge caches on page update etc
3429
	 *
3430
	 * @param Title $title
3431
	 * @param Revision|null $revision Revision that was just saved, may be null
3432
	 */
3433
	public static function onArticleEdit( Title $title, Revision $revision = null ) {
3434
		// Invalidate caches of articles which include this page
3435
		DeferredUpdates::addUpdate( new HTMLCacheUpdate( $title, 'templatelinks' ) );
3436
3437
		// Invalidate the caches of all pages which redirect here
3438
		DeferredUpdates::addUpdate( new HTMLCacheUpdate( $title, 'redirect' ) );
3439
3440
		MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
3441
3442
		// Purge CDN for this page only
3443
		$title->purgeSquid();
3444
		// Clear file cache for this page only
3445
		HTMLFileCache::clearFileCache( $title );
3446
3447
		$revid = $revision ? $revision->getId() : null;
3448
		DeferredUpdates::addCallableUpdate( function() use ( $title, $revid ) {
3449
			InfoAction::invalidateCache( $title, $revid );
3450
		} );
3451
	}
3452
3453
	/**#@-*/
3454
3455
	/**
3456
	 * Returns a list of categories this page is a member of.
3457
	 * Results will include hidden categories
3458
	 *
3459
	 * @return TitleArray
3460
	 */
3461
	public function getCategories() {
3462
		$id = $this->getId();
3463
		if ( $id == 0 ) {
3464
			return TitleArray::newFromResult( new FakeResultWrapper( [] ) );
3465
		}
3466
3467
		$dbr = wfGetDB( DB_REPLICA );
3468
		$res = $dbr->select( 'categorylinks',
3469
			[ 'cl_to AS page_title, ' . NS_CATEGORY . ' AS page_namespace' ],
3470
			// Have to do that since Database::fieldNamesWithAlias treats numeric indexes
3471
			// as not being aliases, and NS_CATEGORY is numeric
3472
			[ 'cl_from' => $id ],
3473
			__METHOD__ );
3474
3475
		return TitleArray::newFromResult( $res );
3476
	}
3477
3478
	/**
3479
	 * Returns a list of hidden categories this page is a member of.
3480
	 * Uses the page_props and categorylinks tables.
3481
	 *
3482
	 * @return array Array of Title objects
3483
	 */
3484
	public function getHiddenCategories() {
3485
		$result = [];
3486
		$id = $this->getId();
3487
3488
		if ( $id == 0 ) {
3489
			return [];
3490
		}
3491
3492
		$dbr = wfGetDB( DB_REPLICA );
3493
		$res = $dbr->select( [ 'categorylinks', 'page_props', 'page' ],
3494
			[ 'cl_to' ],
3495
			[ 'cl_from' => $id, 'pp_page=page_id', 'pp_propname' => 'hiddencat',
3496
				'page_namespace' => NS_CATEGORY, 'page_title=cl_to' ],
3497
			__METHOD__ );
3498
3499
		if ( $res !== false ) {
3500
			foreach ( $res as $row ) {
3501
				$result[] = Title::makeTitle( NS_CATEGORY, $row->cl_to );
3502
			}
3503
		}
3504
3505
		return $result;
3506
	}
3507
3508
	/**
3509
	 * Return an applicable autosummary if one exists for the given edit.
3510
	 * @param string|null $oldtext The previous text of the page.
3511
	 * @param string|null $newtext The submitted text of the page.
3512
	 * @param int $flags Bitmask: a bitmask of flags submitted for the edit.
3513
	 * @return string An appropriate autosummary, or an empty string.
3514
	 *
3515
	 * @deprecated since 1.21, use ContentHandler::getAutosummary() instead
3516
	 */
3517
	public static function getAutosummary( $oldtext, $newtext, $flags ) {
3518
		// NOTE: stub for backwards-compatibility. assumes the given text is
3519
		// wikitext. will break horribly if it isn't.
3520
3521
		wfDeprecated( __METHOD__, '1.21' );
3522
3523
		$handler = ContentHandler::getForModelID( CONTENT_MODEL_WIKITEXT );
3524
		$oldContent = is_null( $oldtext ) ? null : $handler->unserializeContent( $oldtext );
3525
		$newContent = is_null( $newtext ) ? null : $handler->unserializeContent( $newtext );
3526
3527
		return $handler->getAutosummary( $oldContent, $newContent, $flags );
3528
	}
3529
3530
	/**
3531
	 * Auto-generates a deletion reason
3532
	 *
3533
	 * @param bool &$hasHistory Whether the page has a history
3534
	 * @return string|bool String containing deletion reason or empty string, or boolean false
3535
	 *    if no revision occurred
3536
	 */
3537
	public function getAutoDeleteReason( &$hasHistory ) {
3538
		return $this->getContentHandler()->getAutoDeleteReason( $this->getTitle(), $hasHistory );
3539
	}
3540
3541
	/**
3542
	 * Update all the appropriate counts in the category table, given that
3543
	 * we've added the categories $added and deleted the categories $deleted.
3544
	 *
3545
	 * @param array $added The names of categories that were added
3546
	 * @param array $deleted The names of categories that were deleted
3547
	 * @param integer $id Page ID (this should be the original deleted page ID)
3548
	 */
3549
	public function updateCategoryCounts( array $added, array $deleted, $id = 0 ) {
3550
		$id = $id ?: $this->getId();
3551
		$dbw = wfGetDB( DB_MASTER );
3552
		$method = __METHOD__;
3553
		// Do this at the end of the commit to reduce lock wait timeouts
3554
		$dbw->onTransactionPreCommitOrIdle(
3555
			function () use ( $dbw, $added, $deleted, $id, $method ) {
3556
				$ns = $this->getTitle()->getNamespace();
3557
3558
				$addFields = [ 'cat_pages = cat_pages + 1' ];
3559
				$removeFields = [ 'cat_pages = cat_pages - 1' ];
3560
				if ( $ns == NS_CATEGORY ) {
3561
					$addFields[] = 'cat_subcats = cat_subcats + 1';
3562
					$removeFields[] = 'cat_subcats = cat_subcats - 1';
3563
				} elseif ( $ns == NS_FILE ) {
3564
					$addFields[] = 'cat_files = cat_files + 1';
3565
					$removeFields[] = 'cat_files = cat_files - 1';
3566
				}
3567
3568
				if ( count( $added ) ) {
3569
					$existingAdded = $dbw->selectFieldValues(
3570
						'category',
3571
						'cat_title',
3572
						[ 'cat_title' => $added ],
3573
						$method
3574
					);
3575
3576
					// For category rows that already exist, do a plain
3577
					// UPDATE instead of INSERT...ON DUPLICATE KEY UPDATE
3578
					// to avoid creating gaps in the cat_id sequence.
3579
					if ( count( $existingAdded ) ) {
3580
						$dbw->update(
3581
							'category',
3582
							$addFields,
3583
							[ 'cat_title' => $existingAdded ],
3584
							$method
3585
						);
3586
					}
3587
3588
					$missingAdded = array_diff( $added, $existingAdded );
3589
					if ( count( $missingAdded ) ) {
3590
						$insertRows = [];
3591
						foreach ( $missingAdded as $cat ) {
3592
							$insertRows[] = [
3593
								'cat_title'   => $cat,
3594
								'cat_pages'   => 1,
3595
								'cat_subcats' => ( $ns == NS_CATEGORY ) ? 1 : 0,
3596
								'cat_files'   => ( $ns == NS_FILE ) ? 1 : 0,
3597
							];
3598
						}
3599
						$dbw->upsert(
3600
							'category',
3601
							$insertRows,
3602
							[ 'cat_title' ],
3603
							$addFields,
3604
							$method
3605
						);
3606
					}
3607
				}
3608
3609
				if ( count( $deleted ) ) {
3610
					$dbw->update(
3611
						'category',
3612
						$removeFields,
3613
						[ 'cat_title' => $deleted ],
3614
						$method
3615
					);
3616
				}
3617
3618
				foreach ( $added as $catName ) {
3619
					$cat = Category::newFromName( $catName );
3620
					Hooks::run( 'CategoryAfterPageAdded', [ $cat, $this ] );
3621
				}
3622
3623
				foreach ( $deleted as $catName ) {
3624
					$cat = Category::newFromName( $catName );
3625
					Hooks::run( 'CategoryAfterPageRemoved', [ $cat, $this, $id ] );
3626
				}
3627
3628
				// Refresh counts on categories that should be empty now, to
3629
				// trigger possible deletion. Check master for the most
3630
				// up-to-date cat_pages.
3631
				if ( count( $deleted ) ) {
3632
					$rows = $dbw->select(
3633
						'category',
3634
						[ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ],
3635
						[ 'cat_title' => $deleted, 'cat_pages <= 0' ],
3636
						$method
3637
					);
3638
					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...
3639
						$cat = Category::newFromRow( $row );
3640
						$cat->refreshCounts();
3641
					}
3642
				}
3643
			},
3644
			__METHOD__
3645
		);
3646
	}
3647
3648
	/**
3649
	 * Opportunistically enqueue link update jobs given fresh parser output if useful
3650
	 *
3651
	 * @param ParserOutput $parserOutput Current version page output
3652
	 * @since 1.25
3653
	 */
3654
	public function triggerOpportunisticLinksUpdate( ParserOutput $parserOutput ) {
3655
		if ( wfReadOnly() ) {
3656
			return;
3657
		}
3658
3659
		if ( !Hooks::run( 'OpportunisticLinksUpdate',
3660
			[ $this, $this->mTitle, $parserOutput ]
3661
		) ) {
3662
			return;
3663
		}
3664
3665
		$config = RequestContext::getMain()->getConfig();
3666
3667
		$params = [
3668
			'isOpportunistic' => true,
3669
			'rootJobTimestamp' => $parserOutput->getCacheTime()
3670
		];
3671
3672
		if ( $this->mTitle->areRestrictionsCascading() ) {
3673
			// If the page is cascade protecting, the links should really be up-to-date
3674
			JobQueueGroup::singleton()->lazyPush(
3675
				RefreshLinksJob::newPrioritized( $this->mTitle, $params )
3676
			);
3677
		} elseif ( !$config->get( 'MiserMode' ) && $parserOutput->hasDynamicContent() ) {
3678
			// Assume the output contains "dynamic" time/random based magic words.
3679
			// Only update pages that expired due to dynamic content and NOT due to edits
3680
			// to referenced templates/files. When the cache expires due to dynamic content,
3681
			// page_touched is unchanged. We want to avoid triggering redundant jobs due to
3682
			// views of pages that were just purged via HTMLCacheUpdateJob. In that case, the
3683
			// template/file edit already triggered recursive RefreshLinksJob jobs.
3684
			if ( $this->getLinksTimestamp() > $this->getTouched() ) {
3685
				// If a page is uncacheable, do not keep spamming a job for it.
3686
				// Although it would be de-duplicated, it would still waste I/O.
3687
				$cache = ObjectCache::getLocalClusterInstance();
3688
				$key = $cache->makeKey( 'dynamic-linksupdate', 'last', $this->getId() );
3689
				$ttl = max( $parserOutput->getCacheExpiry(), 3600 );
3690
				if ( $cache->add( $key, time(), $ttl ) ) {
3691
					JobQueueGroup::singleton()->lazyPush(
3692
						RefreshLinksJob::newDynamic( $this->mTitle, $params )
3693
					);
3694
				}
3695
			}
3696
		}
3697
	}
3698
3699
	/**
3700
	 * Returns a list of updates to be performed when this page is deleted. The
3701
	 * updates should remove any information about this page from secondary data
3702
	 * stores such as links tables.
3703
	 *
3704
	 * @param Content|null $content Optional Content object for determining the
3705
	 *   necessary updates.
3706
	 * @return DeferrableUpdate[]
3707
	 */
3708
	public function getDeletionUpdates( Content $content = null ) {
3709
		if ( !$content ) {
3710
			// load content object, which may be used to determine the necessary updates.
3711
			// XXX: the content may not be needed to determine the updates.
3712
			try {
3713
				$content = $this->getContent( Revision::RAW );
3714
			} catch ( Exception $ex ) {
3715
				// If we can't load the content, something is wrong. Perhaps that's why
3716
				// the user is trying to delete the page, so let's not fail in that case.
3717
				// Note that doDeleteArticleReal() will already have logged an issue with
3718
				// loading the content.
3719
			}
3720
		}
3721
3722
		if ( !$content ) {
3723
			$updates = [];
3724
		} else {
3725
			$updates = $content->getDeletionUpdates( $this );
3726
		}
3727
3728
		Hooks::run( 'WikiPageDeletionUpdates', [ $this, $content, &$updates ] );
3729
		return $updates;
3730
	}
3731
3732
	/**
3733
	 * Whether this content displayed on this page
3734
	 * comes from the local database
3735
	 *
3736
	 * @since 1.28
3737
	 * @return bool
3738
	 */
3739
	public function isLocal() {
3740
		return true;
3741
	}
3742
}
3743