Completed
Branch master (939199)
by
unknown
39:35
created

includes/Title.php (2 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 * Representation of a title within %MediaWiki.
4
 *
5
 * See title.txt
6
 *
7
 * This program is free software; you can redistribute it and/or modify
8
 * it under the terms of the GNU General Public License as published by
9
 * the Free Software Foundation; either version 2 of the License, or
10
 * (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
 * GNU General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU General Public License along
18
 * with this program; if not, write to the Free Software Foundation, Inc.,
19
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20
 * http://www.gnu.org/copyleft/gpl.html
21
 *
22
 * @file
23
 */
24
use MediaWiki\Linker\LinkTarget;
25
use MediaWiki\Interwiki\InterwikiLookup;
26
use MediaWiki\MediaWikiServices;
27
28
/**
29
 * Represents a title within MediaWiki.
30
 * Optionally may contain an interwiki designation or namespace.
31
 * @note This class can fetch various kinds of data from the database;
32
 *       however, it does so inefficiently.
33
 * @note Consider using a TitleValue object instead. TitleValue is more lightweight
34
 *       and does not rely on global state or the database.
35
 */
36
class Title implements LinkTarget {
37
	/** @var HashBagOStuff */
38
	static private $titleCache = null;
39
40
	/**
41
	 * Title::newFromText maintains a cache to avoid expensive re-normalization of
42
	 * commonly used titles. On a batch operation this can become a memory leak
43
	 * if not bounded. After hitting this many titles reset the cache.
44
	 */
45
	const CACHE_MAX = 1000;
46
47
	/**
48
	 * Used to be GAID_FOR_UPDATE define. Used with getArticleID() and friends
49
	 * to use the master DB
50
	 */
51
	const GAID_FOR_UPDATE = 1;
52
53
	/**
54
	 * @name Private member variables
55
	 * Please use the accessor functions instead.
56
	 * @private
57
	 */
58
	// @{
59
60
	/** @var string Text form (spaces not underscores) of the main part */
61
	public $mTextform = '';
62
63
	/** @var string URL-encoded form of the main part */
64
	public $mUrlform = '';
65
66
	/** @var string Main part with underscores */
67
	public $mDbkeyform = '';
68
69
	/** @var string Database key with the initial letter in the case specified by the user */
70
	protected $mUserCaseDBKey;
71
72
	/** @var int Namespace index, i.e. one of the NS_xxxx constants */
73
	public $mNamespace = NS_MAIN;
74
75
	/** @var string Interwiki prefix */
76
	public $mInterwiki = '';
77
78
	/** @var bool Was this Title created from a string with a local interwiki prefix? */
79
	private $mLocalInterwiki = false;
80
81
	/** @var string Title fragment (i.e. the bit after the #) */
82
	public $mFragment = '';
83
84
	/** @var int Article ID, fetched from the link cache on demand */
85
	public $mArticleID = -1;
86
87
	/** @var bool|int ID of most recent revision */
88
	protected $mLatestID = false;
89
90
	/**
91
	 * @var bool|string ID of the page's content model, i.e. one of the
92
	 *   CONTENT_MODEL_XXX constants
93
	 */
94
	private $mContentModel = false;
95
96
	/**
97
	 * @var bool If a content model was forced via setContentModel()
98
	 *   this will be true to avoid having other code paths reset it
99
	 */
100
	private $mForcedContentModel = false;
101
102
	/** @var int Estimated number of revisions; null of not loaded */
103
	private $mEstimateRevisions;
104
105
	/** @var array Array of groups allowed to edit this article */
106
	public $mRestrictions = [];
107
108
	/** @var string|bool */
109
	protected $mOldRestrictions = false;
110
111
	/** @var bool Cascade restrictions on this page to included templates and images? */
112
	public $mCascadeRestriction;
113
114
	/** Caching the results of getCascadeProtectionSources */
115
	public $mCascadingRestrictions;
116
117
	/** @var array When do the restrictions on this page expire? */
118
	protected $mRestrictionsExpiry = [];
119
120
	/** @var bool Are cascading restrictions in effect on this page? */
121
	protected $mHasCascadingRestrictions;
122
123
	/** @var array Where are the cascading restrictions coming from on this page? */
124
	public $mCascadeSources;
125
126
	/** @var bool Boolean for initialisation on demand */
127
	public $mRestrictionsLoaded = false;
128
129
	/** @var string Text form including namespace/interwiki, initialised on demand */
130
	protected $mPrefixedText = null;
131
132
	/** @var mixed Cached value for getTitleProtection (create protection) */
133
	public $mTitleProtection;
134
135
	/**
136
	 * @var int Namespace index when there is no namespace. Don't change the
137
	 *   following default, NS_MAIN is hardcoded in several places. See bug 696.
138
	 *   Zero except in {{transclusion}} tags.
139
	 */
140
	public $mDefaultNamespace = NS_MAIN;
141
142
	/** @var int The page length, 0 for special pages */
143
	protected $mLength = -1;
144
145
	/** @var null Is the article at this title a redirect? */
146
	public $mRedirect = null;
147
148
	/** @var array Associative array of user ID -> timestamp/false */
149
	private $mNotificationTimestamp = [];
150
151
	/** @var bool Whether a page has any subpages */
152
	private $mHasSubpages;
153
154
	/** @var bool The (string) language code of the page's language and content code. */
155
	private $mPageLanguage = false;
156
157
	/** @var string|bool|null The page language code from the database, null if not saved in
158
	 * the database or false if not loaded, yet. */
159
	private $mDbPageLanguage = false;
160
161
	/** @var TitleValue A corresponding TitleValue object */
162
	private $mTitleValue = null;
163
164
	/** @var bool Would deleting this page be a big deletion? */
165
	private $mIsBigDeletion = null;
166
	// @}
167
168
	/**
169
	 * B/C kludge: provide a TitleParser for use by Title.
170
	 * Ideally, Title would have no methods that need this.
171
	 * Avoid usage of this singleton by using TitleValue
172
	 * and the associated services when possible.
173
	 *
174
	 * @return TitleFormatter
175
	 */
176
	private static function getTitleFormatter() {
177
		return MediaWikiServices::getInstance()->getTitleFormatter();
178
	}
179
180
	/**
181
	 * B/C kludge: provide an InterwikiLookup for use by Title.
182
	 * Ideally, Title would have no methods that need this.
183
	 * Avoid usage of this singleton by using TitleValue
184
	 * and the associated services when possible.
185
	 *
186
	 * @return InterwikiLookup
187
	 */
188
	private static function getInterwikiLookup() {
189
		return MediaWikiServices::getInstance()->getInterwikiLookup();
190
	}
191
192
	/**
193
	 * @access protected
194
	 */
195
	function __construct() {
196
	}
197
198
	/**
199
	 * Create a new Title from a prefixed DB key
200
	 *
201
	 * @param string $key The database key, which has underscores
202
	 *	instead of spaces, possibly including namespace and
203
	 *	interwiki prefixes
204
	 * @return Title|null Title, or null on an error
205
	 */
206
	public static function newFromDBkey( $key ) {
207
		$t = new Title();
208
		$t->mDbkeyform = $key;
209
210
		try {
211
			$t->secureAndSplit();
212
			return $t;
213
		} catch ( MalformedTitleException $ex ) {
214
			return null;
215
		}
216
	}
217
218
	/**
219
	 * Create a new Title from a TitleValue
220
	 *
221
	 * @param TitleValue $titleValue Assumed to be safe.
222
	 *
223
	 * @return Title
224
	 */
225
	public static function newFromTitleValue( TitleValue $titleValue ) {
226
		return self::newFromLinkTarget( $titleValue );
227
	}
228
229
	/**
230
	 * Create a new Title from a LinkTarget
231
	 *
232
	 * @param LinkTarget $linkTarget Assumed to be safe.
233
	 *
234
	 * @return Title
235
	 */
236
	public static function newFromLinkTarget( LinkTarget $linkTarget ) {
237
		if ( $linkTarget instanceof Title ) {
238
			// Special case if it's already a Title object
239
			return $linkTarget;
240
		}
241
		return self::makeTitle(
242
			$linkTarget->getNamespace(),
243
			$linkTarget->getText(),
244
			$linkTarget->getFragment(),
245
			$linkTarget->getInterwiki()
246
		);
247
	}
248
249
	/**
250
	 * Create a new Title from text, such as what one would find in a link. De-
251
	 * codes any HTML entities in the text.
252
	 *
253
	 * @param string|int|null $text The link text; spaces, prefixes, and an
254
	 *   initial ':' indicating the main namespace are accepted.
255
	 * @param int $defaultNamespace The namespace to use if none is specified
256
	 *   by a prefix.  If you want to force a specific namespace even if
257
	 *   $text might begin with a namespace prefix, use makeTitle() or
258
	 *   makeTitleSafe().
259
	 * @throws InvalidArgumentException
260
	 * @return Title|null Title or null on an error.
261
	 */
262
	public static function newFromText( $text, $defaultNamespace = NS_MAIN ) {
263
		// DWIM: Integers can be passed in here when page titles are used as array keys.
264
		if ( $text !== null && !is_string( $text ) && !is_int( $text ) ) {
265
			throw new InvalidArgumentException( '$text must be a string.' );
266
		}
267
		if ( $text === null ) {
268
			return null;
269
		}
270
271
		try {
272
			return Title::newFromTextThrow( strval( $text ), $defaultNamespace );
273
		} catch ( MalformedTitleException $ex ) {
274
			return null;
275
		}
276
	}
277
278
	/**
279
	 * Like Title::newFromText(), but throws MalformedTitleException when the title is invalid,
280
	 * rather than returning null.
281
	 *
282
	 * The exception subclasses encode detailed information about why the title is invalid.
283
	 *
284
	 * @see Title::newFromText
285
	 *
286
	 * @since 1.25
287
	 * @param string $text Title text to check
288
	 * @param int $defaultNamespace
289
	 * @throws MalformedTitleException If the title is invalid
290
	 * @return Title
291
	 */
292
	public static function newFromTextThrow( $text, $defaultNamespace = NS_MAIN ) {
293
		if ( is_object( $text ) ) {
294
			throw new MWException( '$text must be a string, given an object' );
295
		}
296
297
		$titleCache = self::getTitleCache();
298
299
		// Wiki pages often contain multiple links to the same page.
300
		// Title normalization and parsing can become expensive on pages with many
301
		// links, so we can save a little time by caching them.
302
		// In theory these are value objects and won't get changed...
303
		if ( $defaultNamespace == NS_MAIN ) {
304
			$t = $titleCache->get( $text );
305
			if ( $t ) {
306
				return $t;
307
			}
308
		}
309
310
		// Convert things like &eacute; &#257; or &#x3017; into normalized (bug 14952) text
311
		$filteredText = Sanitizer::decodeCharReferencesAndNormalize( $text );
312
313
		$t = new Title();
314
		$t->mDbkeyform = strtr( $filteredText, ' ', '_' );
315
		$t->mDefaultNamespace = intval( $defaultNamespace );
316
317
		$t->secureAndSplit();
318
		if ( $defaultNamespace == NS_MAIN ) {
319
			$titleCache->set( $text, $t );
320
		}
321
		return $t;
322
	}
323
324
	/**
325
	 * THIS IS NOT THE FUNCTION YOU WANT. Use Title::newFromText().
326
	 *
327
	 * Example of wrong and broken code:
328
	 * $title = Title::newFromURL( $wgRequest->getVal( 'title' ) );
329
	 *
330
	 * Example of right code:
331
	 * $title = Title::newFromText( $wgRequest->getVal( 'title' ) );
332
	 *
333
	 * Create a new Title from URL-encoded text. Ensures that
334
	 * the given title's length does not exceed the maximum.
335
	 *
336
	 * @param string $url The title, as might be taken from a URL
337
	 * @return Title|null The new object, or null on an error
338
	 */
339
	public static function newFromURL( $url ) {
340
		$t = new Title();
341
342
		# For compatibility with old buggy URLs. "+" is usually not valid in titles,
343
		# but some URLs used it as a space replacement and they still come
344
		# from some external search tools.
345
		if ( strpos( self::legalChars(), '+' ) === false ) {
346
			$url = strtr( $url, '+', ' ' );
347
		}
348
349
		$t->mDbkeyform = strtr( $url, ' ', '_' );
350
351
		try {
352
			$t->secureAndSplit();
353
			return $t;
354
		} catch ( MalformedTitleException $ex ) {
355
			return null;
356
		}
357
	}
358
359
	/**
360
	 * @return HashBagOStuff
361
	 */
362
	private static function getTitleCache() {
363
		if ( self::$titleCache == null ) {
364
			self::$titleCache = new HashBagOStuff( [ 'maxKeys' => self::CACHE_MAX ] );
365
		}
366
		return self::$titleCache;
367
	}
368
369
	/**
370
	 * Returns a list of fields that are to be selected for initializing Title
371
	 * objects or LinkCache entries. Uses $wgContentHandlerUseDB to determine
372
	 * whether to include page_content_model.
373
	 *
374
	 * @return array
375
	 */
376 View Code Duplication
	protected static function getSelectFields() {
377
		global $wgContentHandlerUseDB, $wgPageLanguageUseDB;
378
379
		$fields = [
380
			'page_namespace', 'page_title', 'page_id',
381
			'page_len', 'page_is_redirect', 'page_latest',
382
		];
383
384
		if ( $wgContentHandlerUseDB ) {
385
			$fields[] = 'page_content_model';
386
		}
387
388
		if ( $wgPageLanguageUseDB ) {
389
			$fields[] = 'page_lang';
390
		}
391
392
		return $fields;
393
	}
394
395
	/**
396
	 * Create a new Title from an article ID
397
	 *
398
	 * @param int $id The page_id corresponding to the Title to create
399
	 * @param int $flags Use Title::GAID_FOR_UPDATE to use master
400
	 * @return Title|null The new object, or null on an error
401
	 */
402
	public static function newFromID( $id, $flags = 0 ) {
403
		$db = ( $flags & self::GAID_FOR_UPDATE ) ? wfGetDB( DB_MASTER ) : wfGetDB( DB_REPLICA );
404
		$row = $db->selectRow(
405
			'page',
406
			self::getSelectFields(),
407
			[ 'page_id' => $id ],
408
			__METHOD__
409
		);
410
		if ( $row !== false ) {
411
			$title = Title::newFromRow( $row );
412
		} else {
413
			$title = null;
414
		}
415
		return $title;
416
	}
417
418
	/**
419
	 * Make an array of titles from an array of IDs
420
	 *
421
	 * @param int[] $ids Array of IDs
422
	 * @return Title[] Array of Titles
423
	 */
424
	public static function newFromIDs( $ids ) {
425
		if ( !count( $ids ) ) {
426
			return [];
427
		}
428
		$dbr = wfGetDB( DB_REPLICA );
429
430
		$res = $dbr->select(
431
			'page',
432
			self::getSelectFields(),
433
			[ 'page_id' => $ids ],
434
			__METHOD__
435
		);
436
437
		$titles = [];
438
		foreach ( $res as $row ) {
439
			$titles[] = Title::newFromRow( $row );
440
		}
441
		return $titles;
442
	}
443
444
	/**
445
	 * Make a Title object from a DB row
446
	 *
447
	 * @param stdClass $row Object database row (needs at least page_title,page_namespace)
448
	 * @return Title Corresponding Title
449
	 */
450
	public static function newFromRow( $row ) {
451
		$t = self::makeTitle( $row->page_namespace, $row->page_title );
452
		$t->loadFromRow( $row );
453
		return $t;
454
	}
455
456
	/**
457
	 * Load Title object fields from a DB row.
458
	 * If false is given, the title will be treated as non-existing.
459
	 *
460
	 * @param stdClass|bool $row Database row
461
	 */
462
	public function loadFromRow( $row ) {
463
		if ( $row ) { // page found
464
			if ( isset( $row->page_id ) ) {
465
				$this->mArticleID = (int)$row->page_id;
466
			}
467
			if ( isset( $row->page_len ) ) {
468
				$this->mLength = (int)$row->page_len;
469
			}
470
			if ( isset( $row->page_is_redirect ) ) {
471
				$this->mRedirect = (bool)$row->page_is_redirect;
472
			}
473
			if ( isset( $row->page_latest ) ) {
474
				$this->mLatestID = (int)$row->page_latest;
475
			}
476
			if ( !$this->mForcedContentModel && isset( $row->page_content_model ) ) {
477
				$this->mContentModel = strval( $row->page_content_model );
478
			} elseif ( !$this->mForcedContentModel ) {
479
				$this->mContentModel = false; # initialized lazily in getContentModel()
480
			}
481
			if ( isset( $row->page_lang ) ) {
482
				$this->mDbPageLanguage = (string)$row->page_lang;
483
			}
484
			if ( isset( $row->page_restrictions ) ) {
485
				$this->mOldRestrictions = $row->page_restrictions;
486
			}
487
		} else { // page not found
488
			$this->mArticleID = 0;
489
			$this->mLength = 0;
490
			$this->mRedirect = false;
491
			$this->mLatestID = 0;
492
			if ( !$this->mForcedContentModel ) {
493
				$this->mContentModel = false; # initialized lazily in getContentModel()
494
			}
495
		}
496
	}
497
498
	/**
499
	 * Create a new Title from a namespace index and a DB key.
500
	 * It's assumed that $ns and $title are *valid*, for instance when
501
	 * they came directly from the database or a special page name.
502
	 * For convenience, spaces are converted to underscores so that
503
	 * eg user_text fields can be used directly.
504
	 *
505
	 * @param int $ns The namespace of the article
506
	 * @param string $title The unprefixed database key form
507
	 * @param string $fragment The link fragment (after the "#")
508
	 * @param string $interwiki The interwiki prefix
509
	 * @return Title The new object
510
	 */
511
	public static function makeTitle( $ns, $title, $fragment = '', $interwiki = '' ) {
512
		$t = new Title();
513
		$t->mInterwiki = $interwiki;
514
		$t->mFragment = $fragment;
515
		$t->mNamespace = $ns = intval( $ns );
516
		$t->mDbkeyform = strtr( $title, ' ', '_' );
517
		$t->mArticleID = ( $ns >= 0 ) ? -1 : 0;
518
		$t->mUrlform = wfUrlencode( $t->mDbkeyform );
519
		$t->mTextform = strtr( $title, '_', ' ' );
520
		$t->mContentModel = false; # initialized lazily in getContentModel()
521
		return $t;
522
	}
523
524
	/**
525
	 * Create a new Title from a namespace index and a DB key.
526
	 * The parameters will be checked for validity, which is a bit slower
527
	 * than makeTitle() but safer for user-provided data.
528
	 *
529
	 * @param int $ns The namespace of the article
530
	 * @param string $title Database key form
531
	 * @param string $fragment The link fragment (after the "#")
532
	 * @param string $interwiki Interwiki prefix
533
	 * @return Title|null The new object, or null on an error
534
	 */
535
	public static function makeTitleSafe( $ns, $title, $fragment = '', $interwiki = '' ) {
536
		if ( !MWNamespace::exists( $ns ) ) {
537
			return null;
538
		}
539
540
		$t = new Title();
541
		$t->mDbkeyform = Title::makeName( $ns, $title, $fragment, $interwiki, true );
542
543
		try {
544
			$t->secureAndSplit();
545
			return $t;
546
		} catch ( MalformedTitleException $ex ) {
547
			return null;
548
		}
549
	}
550
551
	/**
552
	 * Create a new Title for the Main Page
553
	 *
554
	 * @return Title The new object
555
	 */
556
	public static function newMainPage() {
557
		$title = Title::newFromText( wfMessage( 'mainpage' )->inContentLanguage()->text() );
558
		// Don't give fatal errors if the message is broken
559
		if ( !$title ) {
560
			$title = Title::newFromText( 'Main Page' );
561
		}
562
		return $title;
563
	}
564
565
	/**
566
	 * Get the prefixed DB key associated with an ID
567
	 *
568
	 * @param int $id The page_id of the article
569
	 * @return Title|null An object representing the article, or null if no such article was found
570
	 */
571
	public static function nameOf( $id ) {
572
		$dbr = wfGetDB( DB_REPLICA );
573
574
		$s = $dbr->selectRow(
575
			'page',
576
			[ 'page_namespace', 'page_title' ],
577
			[ 'page_id' => $id ],
578
			__METHOD__
579
		);
580
		if ( $s === false ) {
581
			return null;
582
		}
583
584
		$n = self::makeName( $s->page_namespace, $s->page_title );
585
		return $n;
586
	}
587
588
	/**
589
	 * Get a regex character class describing the legal characters in a link
590
	 *
591
	 * @return string The list of characters, not delimited
592
	 */
593
	public static function legalChars() {
594
		global $wgLegalTitleChars;
595
		return $wgLegalTitleChars;
596
	}
597
598
	/**
599
	 * Returns a simple regex that will match on characters and sequences invalid in titles.
600
	 * Note that this doesn't pick up many things that could be wrong with titles, but that
601
	 * replacing this regex with something valid will make many titles valid.
602
	 *
603
	 * @deprecated since 1.25, use MediaWikiTitleCodec::getTitleInvalidRegex() instead
604
	 *
605
	 * @return string Regex string
606
	 */
607
	static function getTitleInvalidRegex() {
608
		wfDeprecated( __METHOD__, '1.25' );
609
		return MediaWikiTitleCodec::getTitleInvalidRegex();
610
	}
611
612
	/**
613
	 * Utility method for converting a character sequence from bytes to Unicode.
614
	 *
615
	 * Primary usecase being converting $wgLegalTitleChars to a sequence usable in
616
	 * javascript, as PHP uses UTF-8 bytes where javascript uses Unicode code units.
617
	 *
618
	 * @param string $byteClass
619
	 * @return string
620
	 */
621
	public static function convertByteClassToUnicodeClass( $byteClass ) {
622
		$length = strlen( $byteClass );
623
		// Input token queue
624
		$x0 = $x1 = $x2 = '';
625
		// Decoded queue
626
		$d0 = $d1 = $d2 = '';
627
		// Decoded integer codepoints
628
		$ord0 = $ord1 = $ord2 = 0;
629
		// Re-encoded queue
630
		$r0 = $r1 = $r2 = '';
631
		// Output
632
		$out = '';
633
		// Flags
634
		$allowUnicode = false;
635
		for ( $pos = 0; $pos < $length; $pos++ ) {
636
			// Shift the queues down
637
			$x2 = $x1;
638
			$x1 = $x0;
639
			$d2 = $d1;
640
			$d1 = $d0;
641
			$ord2 = $ord1;
642
			$ord1 = $ord0;
643
			$r2 = $r1;
644
			$r1 = $r0;
645
			// Load the current input token and decoded values
646
			$inChar = $byteClass[$pos];
647
			if ( $inChar == '\\' ) {
648
				if ( preg_match( '/x([0-9a-fA-F]{2})/A', $byteClass, $m, 0, $pos + 1 ) ) {
649
					$x0 = $inChar . $m[0];
650
					$d0 = chr( hexdec( $m[1] ) );
651
					$pos += strlen( $m[0] );
652
				} elseif ( preg_match( '/[0-7]{3}/A', $byteClass, $m, 0, $pos + 1 ) ) {
653
					$x0 = $inChar . $m[0];
654
					$d0 = chr( octdec( $m[0] ) );
655
					$pos += strlen( $m[0] );
656
				} elseif ( $pos + 1 >= $length ) {
657
					$x0 = $d0 = '\\';
658
				} else {
659
					$d0 = $byteClass[$pos + 1];
660
					$x0 = $inChar . $d0;
661
					$pos += 1;
662
				}
663
			} else {
664
				$x0 = $d0 = $inChar;
665
			}
666
			$ord0 = ord( $d0 );
667
			// Load the current re-encoded value
668
			if ( $ord0 < 32 || $ord0 == 0x7f ) {
669
				$r0 = sprintf( '\x%02x', $ord0 );
670
			} elseif ( $ord0 >= 0x80 ) {
671
				// Allow unicode if a single high-bit character appears
672
				$r0 = sprintf( '\x%02x', $ord0 );
673
				$allowUnicode = true;
674
			} elseif ( strpos( '-\\[]^', $d0 ) !== false ) {
675
				$r0 = '\\' . $d0;
676
			} else {
677
				$r0 = $d0;
678
			}
679
			// Do the output
680
			if ( $x0 !== '' && $x1 === '-' && $x2 !== '' ) {
681
				// Range
682
				if ( $ord2 > $ord0 ) {
683
					// Empty range
684
				} elseif ( $ord0 >= 0x80 ) {
685
					// Unicode range
686
					$allowUnicode = true;
687
					if ( $ord2 < 0x80 ) {
688
						// Keep the non-unicode section of the range
689
						$out .= "$r2-\\x7F";
690
					}
691
				} else {
692
					// Normal range
693
					$out .= "$r2-$r0";
694
				}
695
				// Reset state to the initial value
696
				$x0 = $x1 = $d0 = $d1 = $r0 = $r1 = '';
697
			} elseif ( $ord2 < 0x80 ) {
698
				// ASCII character
699
				$out .= $r2;
700
			}
701
		}
702
		if ( $ord1 < 0x80 ) {
703
			$out .= $r1;
704
		}
705
		if ( $ord0 < 0x80 ) {
706
			$out .= $r0;
707
		}
708
		if ( $allowUnicode ) {
709
			$out .= '\u0080-\uFFFF';
710
		}
711
		return $out;
712
	}
713
714
	/**
715
	 * Make a prefixed DB key from a DB key and a namespace index
716
	 *
717
	 * @param int $ns Numerical representation of the namespace
718
	 * @param string $title The DB key form the title
719
	 * @param string $fragment The link fragment (after the "#")
720
	 * @param string $interwiki The interwiki prefix
721
	 * @param bool $canonicalNamespace If true, use the canonical name for
722
	 *   $ns instead of the localized version.
723
	 * @return string The prefixed form of the title
724
	 */
725
	public static function makeName( $ns, $title, $fragment = '', $interwiki = '',
726
		$canonicalNamespace = false
727
	) {
728
		global $wgContLang;
729
730
		if ( $canonicalNamespace ) {
731
			$namespace = MWNamespace::getCanonicalName( $ns );
732
		} else {
733
			$namespace = $wgContLang->getNsText( $ns );
734
		}
735
		$name = $namespace == '' ? $title : "$namespace:$title";
736
		if ( strval( $interwiki ) != '' ) {
737
			$name = "$interwiki:$name";
738
		}
739
		if ( strval( $fragment ) != '' ) {
740
			$name .= '#' . $fragment;
741
		}
742
		return $name;
743
	}
744
745
	/**
746
	 * Escape a text fragment, say from a link, for a URL
747
	 *
748
	 * @param string $fragment Containing a URL or link fragment (after the "#")
749
	 * @return string Escaped string
750
	 */
751
	static function escapeFragmentForURL( $fragment ) {
752
		# Note that we don't urlencode the fragment.  urlencoded Unicode
753
		# fragments appear not to work in IE (at least up to 7) or in at least
754
		# one version of Opera 9.x.  The W3C validator, for one, doesn't seem
755
		# to care if they aren't encoded.
756
		return Sanitizer::escapeId( $fragment, 'noninitial' );
757
	}
758
759
	/**
760
	 * Callback for usort() to do title sorts by (namespace, title)
761
	 *
762
	 * @param LinkTarget $a
763
	 * @param LinkTarget $b
764
	 *
765
	 * @return int Result of string comparison, or namespace comparison
766
	 */
767
	public static function compare( LinkTarget $a, LinkTarget $b ) {
768
		if ( $a->getNamespace() == $b->getNamespace() ) {
769
			return strcmp( $a->getText(), $b->getText() );
770
		} else {
771
			return $a->getNamespace() - $b->getNamespace();
772
		}
773
	}
774
775
	/**
776
	 * Determine whether the object refers to a page within
777
	 * this project (either this wiki or a wiki with a local
778
	 * interwiki, see https://www.mediawiki.org/wiki/Manual:Interwiki_table#iw_local )
779
	 *
780
	 * @return bool True if this is an in-project interwiki link or a wikilink, false otherwise
781
	 */
782
	public function isLocal() {
783
		if ( $this->isExternal() ) {
784
			$iw = self::getInterwikiLookup()->fetch( $this->mInterwiki );
785
			if ( $iw ) {
786
				return $iw->isLocal();
787
			}
788
		}
789
		return true;
790
	}
791
792
	/**
793
	 * Is this Title interwiki?
794
	 *
795
	 * @return bool
796
	 */
797
	public function isExternal() {
798
		return $this->mInterwiki !== '';
799
	}
800
801
	/**
802
	 * Get the interwiki prefix
803
	 *
804
	 * Use Title::isExternal to check if a interwiki is set
805
	 *
806
	 * @return string Interwiki prefix
807
	 */
808
	public function getInterwiki() {
809
		return $this->mInterwiki;
810
	}
811
812
	/**
813
	 * Was this a local interwiki link?
814
	 *
815
	 * @return bool
816
	 */
817
	public function wasLocalInterwiki() {
818
		return $this->mLocalInterwiki;
819
	}
820
821
	/**
822
	 * Determine whether the object refers to a page within
823
	 * this project and is transcludable.
824
	 *
825
	 * @return bool True if this is transcludable
826
	 */
827
	public function isTrans() {
828
		if ( !$this->isExternal() ) {
829
			return false;
830
		}
831
832
		return self::getInterwikiLookup()->fetch( $this->mInterwiki )->isTranscludable();
833
	}
834
835
	/**
836
	 * Returns the DB name of the distant wiki which owns the object.
837
	 *
838
	 * @return string The DB name
839
	 */
840
	public function getTransWikiID() {
841
		if ( !$this->isExternal() ) {
842
			return false;
843
		}
844
845
		return self::getInterwikiLookup()->fetch( $this->mInterwiki )->getWikiID();
846
	}
847
848
	/**
849
	 * Get a TitleValue object representing this Title.
850
	 *
851
	 * @note Not all valid Titles have a corresponding valid TitleValue
852
	 * (e.g. TitleValues cannot represent page-local links that have a
853
	 * fragment but no title text).
854
	 *
855
	 * @return TitleValue|null
856
	 */
857
	public function getTitleValue() {
858
		if ( $this->mTitleValue === null ) {
859
			try {
860
				$this->mTitleValue = new TitleValue(
861
					$this->getNamespace(),
862
					$this->getDBkey(),
863
					$this->getFragment(),
864
					$this->getInterwiki()
865
				);
866
			} catch ( InvalidArgumentException $ex ) {
867
				wfDebug( __METHOD__ . ': Can\'t create a TitleValue for [[' .
868
					$this->getPrefixedText() . ']]: ' . $ex->getMessage() . "\n" );
869
			}
870
		}
871
872
		return $this->mTitleValue;
873
	}
874
875
	/**
876
	 * Get the text form (spaces not underscores) of the main part
877
	 *
878
	 * @return string Main part of the title
879
	 */
880
	public function getText() {
881
		return $this->mTextform;
882
	}
883
884
	/**
885
	 * Get the URL-encoded form of the main part
886
	 *
887
	 * @return string Main part of the title, URL-encoded
888
	 */
889
	public function getPartialURL() {
890
		return $this->mUrlform;
891
	}
892
893
	/**
894
	 * Get the main part with underscores
895
	 *
896
	 * @return string Main part of the title, with underscores
897
	 */
898
	public function getDBkey() {
899
		return $this->mDbkeyform;
900
	}
901
902
	/**
903
	 * Get the DB key with the initial letter case as specified by the user
904
	 *
905
	 * @return string DB key
906
	 */
907
	function getUserCaseDBKey() {
908
		if ( !is_null( $this->mUserCaseDBKey ) ) {
909
			return $this->mUserCaseDBKey;
910
		} else {
911
			// If created via makeTitle(), $this->mUserCaseDBKey is not set.
912
			return $this->mDbkeyform;
913
		}
914
	}
915
916
	/**
917
	 * Get the namespace index, i.e. one of the NS_xxxx constants.
918
	 *
919
	 * @return int Namespace index
920
	 */
921
	public function getNamespace() {
922
		return $this->mNamespace;
923
	}
924
925
	/**
926
	 * Get the page's content model id, see the CONTENT_MODEL_XXX constants.
927
	 *
928
	 * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select for update
929
	 * @return string Content model id
930
	 */
931
	public function getContentModel( $flags = 0 ) {
932
		if ( !$this->mForcedContentModel
933
			&& ( !$this->mContentModel || $flags === Title::GAID_FOR_UPDATE )
934
			&& $this->getArticleID( $flags )
935
		) {
936
			$linkCache = LinkCache::singleton();
937
			$linkCache->addLinkObj( $this ); # in case we already had an article ID
938
			$this->mContentModel = $linkCache->getGoodLinkFieldObj( $this, 'model' );
939
		}
940
941
		if ( !$this->mContentModel ) {
942
			$this->mContentModel = ContentHandler::getDefaultModelFor( $this );
943
		}
944
945
		return $this->mContentModel;
946
	}
947
948
	/**
949
	 * Convenience method for checking a title's content model name
950
	 *
951
	 * @param string $id The content model ID (use the CONTENT_MODEL_XXX constants).
952
	 * @return bool True if $this->getContentModel() == $id
953
	 */
954
	public function hasContentModel( $id ) {
955
		return $this->getContentModel() == $id;
956
	}
957
958
	/**
959
	 * Set a proposed content model for the page for permissions
960
	 * checking. This does not actually change the content model
961
	 * of a title!
962
	 *
963
	 * Additionally, you should make sure you've checked
964
	 * ContentHandler::canBeUsedOn() first.
965
	 *
966
	 * @since 1.28
967
	 * @param string $model CONTENT_MODEL_XXX constant
968
	 */
969
	public function setContentModel( $model ) {
970
		$this->mContentModel = $model;
971
		$this->mForcedContentModel = true;
972
	}
973
974
	/**
975
	 * Get the namespace text
976
	 *
977
	 * @return string Namespace text
978
	 */
979
	public function getNsText() {
980
		if ( $this->isExternal() ) {
981
			// This probably shouldn't even happen,
982
			// but for interwiki transclusion it sometimes does.
983
			// Use the canonical namespaces if possible to try to
984
			// resolve a foreign namespace.
985
			if ( MWNamespace::exists( $this->mNamespace ) ) {
986
				return MWNamespace::getCanonicalName( $this->mNamespace );
987
			}
988
		}
989
990
		try {
991
			$formatter = self::getTitleFormatter();
992
			return $formatter->getNamespaceName( $this->mNamespace, $this->mDbkeyform );
993
		} catch ( InvalidArgumentException $ex ) {
994
			wfDebug( __METHOD__ . ': ' . $ex->getMessage() . "\n" );
995
			return false;
996
		}
997
	}
998
999
	/**
1000
	 * Get the namespace text of the subject (rather than talk) page
1001
	 *
1002
	 * @return string Namespace text
1003
	 */
1004
	public function getSubjectNsText() {
1005
		global $wgContLang;
1006
		return $wgContLang->getNsText( MWNamespace::getSubject( $this->mNamespace ) );
1007
	}
1008
1009
	/**
1010
	 * Get the namespace text of the talk page
1011
	 *
1012
	 * @return string Namespace text
1013
	 */
1014
	public function getTalkNsText() {
1015
		global $wgContLang;
1016
		return $wgContLang->getNsText( MWNamespace::getTalk( $this->mNamespace ) );
1017
	}
1018
1019
	/**
1020
	 * Could this title have a corresponding talk page?
1021
	 *
1022
	 * @return bool
1023
	 */
1024
	public function canTalk() {
1025
		return MWNamespace::canTalk( $this->mNamespace );
1026
	}
1027
1028
	/**
1029
	 * Is this in a namespace that allows actual pages?
1030
	 *
1031
	 * @return bool
1032
	 */
1033
	public function canExist() {
1034
		return $this->mNamespace >= NS_MAIN;
1035
	}
1036
1037
	/**
1038
	 * Can this title be added to a user's watchlist?
1039
	 *
1040
	 * @return bool
1041
	 */
1042
	public function isWatchable() {
1043
		return !$this->isExternal() && MWNamespace::isWatchable( $this->getNamespace() );
1044
	}
1045
1046
	/**
1047
	 * Returns true if this is a special page.
1048
	 *
1049
	 * @return bool
1050
	 */
1051
	public function isSpecialPage() {
1052
		return $this->getNamespace() == NS_SPECIAL;
1053
	}
1054
1055
	/**
1056
	 * Returns true if this title resolves to the named special page
1057
	 *
1058
	 * @param string $name The special page name
1059
	 * @return bool
1060
	 */
1061
	public function isSpecial( $name ) {
1062
		if ( $this->isSpecialPage() ) {
1063
			list( $thisName, /* $subpage */ ) = SpecialPageFactory::resolveAlias( $this->getDBkey() );
1064
			if ( $name == $thisName ) {
1065
				return true;
1066
			}
1067
		}
1068
		return false;
1069
	}
1070
1071
	/**
1072
	 * If the Title refers to a special page alias which is not the local default, resolve
1073
	 * the alias, and localise the name as necessary.  Otherwise, return $this
1074
	 *
1075
	 * @return Title
1076
	 */
1077
	public function fixSpecialName() {
1078
		if ( $this->isSpecialPage() ) {
1079
			list( $canonicalName, $par ) = SpecialPageFactory::resolveAlias( $this->mDbkeyform );
1080
			if ( $canonicalName ) {
1081
				$localName = SpecialPageFactory::getLocalNameFor( $canonicalName, $par );
1082
				if ( $localName != $this->mDbkeyform ) {
1083
					return Title::makeTitle( NS_SPECIAL, $localName );
1084
				}
1085
			}
1086
		}
1087
		return $this;
1088
	}
1089
1090
	/**
1091
	 * Returns true if the title is inside the specified namespace.
1092
	 *
1093
	 * Please make use of this instead of comparing to getNamespace()
1094
	 * This function is much more resistant to changes we may make
1095
	 * to namespaces than code that makes direct comparisons.
1096
	 * @param int $ns The namespace
1097
	 * @return bool
1098
	 * @since 1.19
1099
	 */
1100
	public function inNamespace( $ns ) {
1101
		return MWNamespace::equals( $this->getNamespace(), $ns );
1102
	}
1103
1104
	/**
1105
	 * Returns true if the title is inside one of the specified namespaces.
1106
	 *
1107
	 * @param int|int[] $namespaces,... The namespaces to check for
1108
	 * @return bool
1109
	 * @since 1.19
1110
	 */
1111
	public function inNamespaces( /* ... */ ) {
1112
		$namespaces = func_get_args();
1113
		if ( count( $namespaces ) > 0 && is_array( $namespaces[0] ) ) {
1114
			$namespaces = $namespaces[0];
1115
		}
1116
1117
		foreach ( $namespaces as $ns ) {
1118
			if ( $this->inNamespace( $ns ) ) {
1119
				return true;
1120
			}
1121
		}
1122
1123
		return false;
1124
	}
1125
1126
	/**
1127
	 * Returns true if the title has the same subject namespace as the
1128
	 * namespace specified.
1129
	 * For example this method will take NS_USER and return true if namespace
1130
	 * is either NS_USER or NS_USER_TALK since both of them have NS_USER
1131
	 * as their subject namespace.
1132
	 *
1133
	 * This is MUCH simpler than individually testing for equivalence
1134
	 * against both NS_USER and NS_USER_TALK, and is also forward compatible.
1135
	 * @since 1.19
1136
	 * @param int $ns
1137
	 * @return bool
1138
	 */
1139
	public function hasSubjectNamespace( $ns ) {
1140
		return MWNamespace::subjectEquals( $this->getNamespace(), $ns );
1141
	}
1142
1143
	/**
1144
	 * Is this Title in a namespace which contains content?
1145
	 * In other words, is this a content page, for the purposes of calculating
1146
	 * statistics, etc?
1147
	 *
1148
	 * @return bool
1149
	 */
1150
	public function isContentPage() {
1151
		return MWNamespace::isContent( $this->getNamespace() );
1152
	}
1153
1154
	/**
1155
	 * Would anybody with sufficient privileges be able to move this page?
1156
	 * Some pages just aren't movable.
1157
	 *
1158
	 * @return bool
1159
	 */
1160
	public function isMovable() {
1161
		if ( !MWNamespace::isMovable( $this->getNamespace() ) || $this->isExternal() ) {
1162
			// Interwiki title or immovable namespace. Hooks don't get to override here
1163
			return false;
1164
		}
1165
1166
		$result = true;
1167
		Hooks::run( 'TitleIsMovable', [ $this, &$result ] );
1168
		return $result;
1169
	}
1170
1171
	/**
1172
	 * Is this the mainpage?
1173
	 * @note Title::newFromText seems to be sufficiently optimized by the title
1174
	 * cache that we don't need to over-optimize by doing direct comparisons and
1175
	 * accidentally creating new bugs where $title->equals( Title::newFromText() )
1176
	 * ends up reporting something differently than $title->isMainPage();
1177
	 *
1178
	 * @since 1.18
1179
	 * @return bool
1180
	 */
1181
	public function isMainPage() {
1182
		return $this->equals( Title::newMainPage() );
1183
	}
1184
1185
	/**
1186
	 * Is this a subpage?
1187
	 *
1188
	 * @return bool
1189
	 */
1190
	public function isSubpage() {
1191
		return MWNamespace::hasSubpages( $this->mNamespace )
1192
			? strpos( $this->getText(), '/' ) !== false
1193
			: false;
1194
	}
1195
1196
	/**
1197
	 * Is this a conversion table for the LanguageConverter?
1198
	 *
1199
	 * @return bool
1200
	 */
1201
	public function isConversionTable() {
1202
		// @todo ConversionTable should become a separate content model.
1203
1204
		return $this->getNamespace() == NS_MEDIAWIKI &&
1205
			strpos( $this->getText(), 'Conversiontable/' ) === 0;
1206
	}
1207
1208
	/**
1209
	 * Does that page contain wikitext, or it is JS, CSS or whatever?
1210
	 *
1211
	 * @return bool
1212
	 */
1213
	public function isWikitextPage() {
1214
		return $this->hasContentModel( CONTENT_MODEL_WIKITEXT );
1215
	}
1216
1217
	/**
1218
	 * Could this page contain custom CSS or JavaScript for the global UI.
1219
	 * This is generally true for pages in the MediaWiki namespace having CONTENT_MODEL_CSS
1220
	 * or CONTENT_MODEL_JAVASCRIPT.
1221
	 *
1222
	 * This method does *not* return true for per-user JS/CSS. Use isCssJsSubpage()
1223
	 * for that!
1224
	 *
1225
	 * Note that this method should not return true for pages that contain and
1226
	 * show "inactive" CSS or JS.
1227
	 *
1228
	 * @return bool
1229
	 * @todo FIXME: Rename to isSiteConfigPage() and remove deprecated hook
1230
	 */
1231
	public function isCssOrJsPage() {
1232
		$isCssOrJsPage = NS_MEDIAWIKI == $this->mNamespace
1233
			&& ( $this->hasContentModel( CONTENT_MODEL_CSS )
1234
				|| $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) );
1235
1236
		# @note This hook is also called in ContentHandler::getDefaultModel.
1237
		#   It's called here again to make sure hook functions can force this
1238
		#   method to return true even outside the MediaWiki namespace.
1239
1240
		Hooks::run( 'TitleIsCssOrJsPage', [ $this, &$isCssOrJsPage ], '1.25' );
1241
1242
		return $isCssOrJsPage;
1243
	}
1244
1245
	/**
1246
	 * Is this a .css or .js subpage of a user page?
1247
	 * @return bool
1248
	 * @todo FIXME: Rename to isUserConfigPage()
1249
	 */
1250
	public function isCssJsSubpage() {
1251
		return ( NS_USER == $this->mNamespace && $this->isSubpage()
1252
				&& ( $this->hasContentModel( CONTENT_MODEL_CSS )
1253
					|| $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) ) );
1254
	}
1255
1256
	/**
1257
	 * Trim down a .css or .js subpage title to get the corresponding skin name
1258
	 *
1259
	 * @return string Containing skin name from .css or .js subpage title
1260
	 */
1261
	public function getSkinFromCssJsSubpage() {
1262
		$subpage = explode( '/', $this->mTextform );
1263
		$subpage = $subpage[count( $subpage ) - 1];
1264
		$lastdot = strrpos( $subpage, '.' );
1265
		if ( $lastdot === false ) {
1266
			return $subpage; # Never happens: only called for names ending in '.css' or '.js'
1267
		}
1268
		return substr( $subpage, 0, $lastdot );
1269
	}
1270
1271
	/**
1272
	 * Is this a .css subpage of a user page?
1273
	 *
1274
	 * @return bool
1275
	 */
1276
	public function isCssSubpage() {
1277
		return ( NS_USER == $this->mNamespace && $this->isSubpage()
1278
			&& $this->hasContentModel( CONTENT_MODEL_CSS ) );
1279
	}
1280
1281
	/**
1282
	 * Is this a .js subpage of a user page?
1283
	 *
1284
	 * @return bool
1285
	 */
1286
	public function isJsSubpage() {
1287
		return ( NS_USER == $this->mNamespace && $this->isSubpage()
1288
			&& $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) );
1289
	}
1290
1291
	/**
1292
	 * Is this a talk page of some sort?
1293
	 *
1294
	 * @return bool
1295
	 */
1296
	public function isTalkPage() {
1297
		return MWNamespace::isTalk( $this->getNamespace() );
1298
	}
1299
1300
	/**
1301
	 * Get a Title object associated with the talk page of this article
1302
	 *
1303
	 * @return Title The object for the talk page
1304
	 */
1305
	public function getTalkPage() {
1306
		return Title::makeTitle( MWNamespace::getTalk( $this->getNamespace() ), $this->getDBkey() );
1307
	}
1308
1309
	/**
1310
	 * Get a title object associated with the subject page of this
1311
	 * talk page
1312
	 *
1313
	 * @return Title The object for the subject page
1314
	 */
1315
	public function getSubjectPage() {
1316
		// Is this the same title?
1317
		$subjectNS = MWNamespace::getSubject( $this->getNamespace() );
1318
		if ( $this->getNamespace() == $subjectNS ) {
1319
			return $this;
1320
		}
1321
		return Title::makeTitle( $subjectNS, $this->getDBkey() );
1322
	}
1323
1324
	/**
1325
	 * Get the other title for this page, if this is a subject page
1326
	 * get the talk page, if it is a subject page get the talk page
1327
	 *
1328
	 * @since 1.25
1329
	 * @throws MWException
1330
	 * @return Title
1331
	 */
1332
	public function getOtherPage() {
1333
		if ( $this->isSpecialPage() ) {
1334
			throw new MWException( 'Special pages cannot have other pages' );
1335
		}
1336
		if ( $this->isTalkPage() ) {
1337
			return $this->getSubjectPage();
1338
		} else {
1339
			return $this->getTalkPage();
1340
		}
1341
	}
1342
1343
	/**
1344
	 * Get the default namespace index, for when there is no namespace
1345
	 *
1346
	 * @return int Default namespace index
1347
	 */
1348
	public function getDefaultNamespace() {
1349
		return $this->mDefaultNamespace;
1350
	}
1351
1352
	/**
1353
	 * Get the Title fragment (i.e.\ the bit after the #) in text form
1354
	 *
1355
	 * Use Title::hasFragment to check for a fragment
1356
	 *
1357
	 * @return string Title fragment
1358
	 */
1359
	public function getFragment() {
1360
		return $this->mFragment;
1361
	}
1362
1363
	/**
1364
	 * Check if a Title fragment is set
1365
	 *
1366
	 * @return bool
1367
	 * @since 1.23
1368
	 */
1369
	public function hasFragment() {
1370
		return $this->mFragment !== '';
1371
	}
1372
1373
	/**
1374
	 * Get the fragment in URL form, including the "#" character if there is one
1375
	 * @return string Fragment in URL form
1376
	 */
1377
	public function getFragmentForURL() {
1378
		if ( !$this->hasFragment() ) {
1379
			return '';
1380
		} else {
1381
			return '#' . Title::escapeFragmentForURL( $this->getFragment() );
1382
		}
1383
	}
1384
1385
	/**
1386
	 * Set the fragment for this title. Removes the first character from the
1387
	 * specified fragment before setting, so it assumes you're passing it with
1388
	 * an initial "#".
1389
	 *
1390
	 * Deprecated for public use, use Title::makeTitle() with fragment parameter,
1391
	 * or Title::createFragmentTarget().
1392
	 * Still in active use privately.
1393
	 *
1394
	 * @private
1395
	 * @param string $fragment Text
1396
	 */
1397
	public function setFragment( $fragment ) {
1398
		$this->mFragment = strtr( substr( $fragment, 1 ), '_', ' ' );
1399
	}
1400
1401
	/**
1402
	 * Creates a new Title for a different fragment of the same page.
1403
	 *
1404
	 * @since 1.27
1405
	 * @param string $fragment
1406
	 * @return Title
1407
	 */
1408
	public function createFragmentTarget( $fragment ) {
1409
		return self::makeTitle(
1410
			$this->getNamespace(),
1411
			$this->getText(),
1412
			$fragment,
1413
			$this->getInterwiki()
1414
		);
1415
1416
	}
1417
1418
	/**
1419
	 * Prefix some arbitrary text with the namespace or interwiki prefix
1420
	 * of this object
1421
	 *
1422
	 * @param string $name The text
1423
	 * @return string The prefixed text
1424
	 */
1425
	private function prefix( $name ) {
1426
		$p = '';
1427
		if ( $this->isExternal() ) {
1428
			$p = $this->mInterwiki . ':';
1429
		}
1430
1431
		if ( 0 != $this->mNamespace ) {
1432
			$p .= $this->getNsText() . ':';
1433
		}
1434
		return $p . $name;
1435
	}
1436
1437
	/**
1438
	 * Get the prefixed database key form
1439
	 *
1440
	 * @return string The prefixed title, with underscores and
1441
	 *  any interwiki and namespace prefixes
1442
	 */
1443
	public function getPrefixedDBkey() {
1444
		$s = $this->prefix( $this->mDbkeyform );
1445
		$s = strtr( $s, ' ', '_' );
1446
		return $s;
1447
	}
1448
1449
	/**
1450
	 * Get the prefixed title with spaces.
1451
	 * This is the form usually used for display
1452
	 *
1453
	 * @return string The prefixed title, with spaces
1454
	 */
1455
	public function getPrefixedText() {
1456
		if ( $this->mPrefixedText === null ) {
1457
			$s = $this->prefix( $this->mTextform );
1458
			$s = strtr( $s, '_', ' ' );
1459
			$this->mPrefixedText = $s;
1460
		}
1461
		return $this->mPrefixedText;
1462
	}
1463
1464
	/**
1465
	 * Return a string representation of this title
1466
	 *
1467
	 * @return string Representation of this title
1468
	 */
1469
	public function __toString() {
1470
		return $this->getPrefixedText();
1471
	}
1472
1473
	/**
1474
	 * Get the prefixed title with spaces, plus any fragment
1475
	 * (part beginning with '#')
1476
	 *
1477
	 * @return string The prefixed title, with spaces and the fragment, including '#'
1478
	 */
1479
	public function getFullText() {
1480
		$text = $this->getPrefixedText();
1481
		if ( $this->hasFragment() ) {
1482
			$text .= '#' . $this->getFragment();
1483
		}
1484
		return $text;
1485
	}
1486
1487
	/**
1488
	 * Get the root page name text without a namespace, i.e. the leftmost part before any slashes
1489
	 *
1490
	 * @par Example:
1491
	 * @code
1492
	 * Title::newFromText('User:Foo/Bar/Baz')->getRootText();
1493
	 * # returns: 'Foo'
1494
	 * @endcode
1495
	 *
1496
	 * @return string Root name
1497
	 * @since 1.20
1498
	 */
1499
	public function getRootText() {
1500
		if ( !MWNamespace::hasSubpages( $this->mNamespace ) ) {
1501
			return $this->getText();
1502
		}
1503
1504
		return strtok( $this->getText(), '/' );
1505
	}
1506
1507
	/**
1508
	 * Get the root page name title, i.e. the leftmost part before any slashes
1509
	 *
1510
	 * @par Example:
1511
	 * @code
1512
	 * Title::newFromText('User:Foo/Bar/Baz')->getRootTitle();
1513
	 * # returns: Title{User:Foo}
1514
	 * @endcode
1515
	 *
1516
	 * @return Title Root title
1517
	 * @since 1.20
1518
	 */
1519
	public function getRootTitle() {
1520
		return Title::makeTitle( $this->getNamespace(), $this->getRootText() );
1521
	}
1522
1523
	/**
1524
	 * Get the base page name without a namespace, i.e. the part before the subpage name
1525
	 *
1526
	 * @par Example:
1527
	 * @code
1528
	 * Title::newFromText('User:Foo/Bar/Baz')->getBaseText();
1529
	 * # returns: 'Foo/Bar'
1530
	 * @endcode
1531
	 *
1532
	 * @return string Base name
1533
	 */
1534
	public function getBaseText() {
1535
		if ( !MWNamespace::hasSubpages( $this->mNamespace ) ) {
1536
			return $this->getText();
1537
		}
1538
1539
		$parts = explode( '/', $this->getText() );
1540
		# Don't discard the real title if there's no subpage involved
1541
		if ( count( $parts ) > 1 ) {
1542
			unset( $parts[count( $parts ) - 1] );
1543
		}
1544
		return implode( '/', $parts );
1545
	}
1546
1547
	/**
1548
	 * Get the base page name title, i.e. the part before the subpage name
1549
	 *
1550
	 * @par Example:
1551
	 * @code
1552
	 * Title::newFromText('User:Foo/Bar/Baz')->getBaseTitle();
1553
	 * # returns: Title{User:Foo/Bar}
1554
	 * @endcode
1555
	 *
1556
	 * @return Title Base title
1557
	 * @since 1.20
1558
	 */
1559
	public function getBaseTitle() {
1560
		return Title::makeTitle( $this->getNamespace(), $this->getBaseText() );
1561
	}
1562
1563
	/**
1564
	 * Get the lowest-level subpage name, i.e. the rightmost part after any slashes
1565
	 *
1566
	 * @par Example:
1567
	 * @code
1568
	 * Title::newFromText('User:Foo/Bar/Baz')->getSubpageText();
1569
	 * # returns: "Baz"
1570
	 * @endcode
1571
	 *
1572
	 * @return string Subpage name
1573
	 */
1574
	public function getSubpageText() {
1575
		if ( !MWNamespace::hasSubpages( $this->mNamespace ) ) {
1576
			return $this->mTextform;
1577
		}
1578
		$parts = explode( '/', $this->mTextform );
1579
		return $parts[count( $parts ) - 1];
1580
	}
1581
1582
	/**
1583
	 * Get the title for a subpage of the current page
1584
	 *
1585
	 * @par Example:
1586
	 * @code
1587
	 * Title::newFromText('User:Foo/Bar/Baz')->getSubpage("Asdf");
1588
	 * # returns: Title{User:Foo/Bar/Baz/Asdf}
1589
	 * @endcode
1590
	 *
1591
	 * @param string $text The subpage name to add to the title
1592
	 * @return Title Subpage title
1593
	 * @since 1.20
1594
	 */
1595
	public function getSubpage( $text ) {
1596
		return Title::makeTitleSafe( $this->getNamespace(), $this->getText() . '/' . $text );
1597
	}
1598
1599
	/**
1600
	 * Get a URL-encoded form of the subpage text
1601
	 *
1602
	 * @return string URL-encoded subpage name
1603
	 */
1604
	public function getSubpageUrlForm() {
1605
		$text = $this->getSubpageText();
1606
		$text = wfUrlencode( strtr( $text, ' ', '_' ) );
1607
		return $text;
1608
	}
1609
1610
	/**
1611
	 * Get a URL-encoded title (not an actual URL) including interwiki
1612
	 *
1613
	 * @return string The URL-encoded form
1614
	 */
1615
	public function getPrefixedURL() {
1616
		$s = $this->prefix( $this->mDbkeyform );
1617
		$s = wfUrlencode( strtr( $s, ' ', '_' ) );
1618
		return $s;
1619
	}
1620
1621
	/**
1622
	 * Helper to fix up the get{Canonical,Full,Link,Local,Internal}URL args
1623
	 * get{Canonical,Full,Link,Local,Internal}URL methods accepted an optional
1624
	 * second argument named variant. This was deprecated in favor
1625
	 * of passing an array of option with a "variant" key
1626
	 * Once $query2 is removed for good, this helper can be dropped
1627
	 * and the wfArrayToCgi moved to getLocalURL();
1628
	 *
1629
	 * @since 1.19 (r105919)
1630
	 * @param array|string $query
1631
	 * @param bool $query2
1632
	 * @return string
1633
	 */
1634
	private static function fixUrlQueryArgs( $query, $query2 = false ) {
1635
		if ( $query2 !== false ) {
1636
			wfDeprecated( "Title::get{Canonical,Full,Link,Local,Internal}URL " .
1637
				"method called with a second parameter is deprecated. Add your " .
1638
				"parameter to an array passed as the first parameter.", "1.19" );
1639
		}
1640
		if ( is_array( $query ) ) {
1641
			$query = wfArrayToCgi( $query );
1642
		}
1643
		if ( $query2 ) {
1644
			if ( is_string( $query2 ) ) {
1645
				// $query2 is a string, we will consider this to be
1646
				// a deprecated $variant argument and add it to the query
1647
				$query2 = wfArrayToCgi( [ 'variant' => $query2 ] );
1648
			} else {
1649
				$query2 = wfArrayToCgi( $query2 );
1650
			}
1651
			// If we have $query content add a & to it first
1652
			if ( $query ) {
1653
				$query .= '&';
1654
			}
1655
			// Now append the queries together
1656
			$query .= $query2;
1657
		}
1658
		return $query;
1659
	}
1660
1661
	/**
1662
	 * Get a real URL referring to this title, with interwiki link and
1663
	 * fragment
1664
	 *
1665
	 * @see self::getLocalURL for the arguments.
1666
	 * @see wfExpandUrl
1667
	 * @param array|string $query
1668
	 * @param bool $query2
1669
	 * @param string $proto Protocol type to use in URL
1670
	 * @return string The URL
1671
	 */
1672
	public function getFullURL( $query = '', $query2 = false, $proto = PROTO_RELATIVE ) {
1673
		$query = self::fixUrlQueryArgs( $query, $query2 );
1674
1675
		# Hand off all the decisions on urls to getLocalURL
1676
		$url = $this->getLocalURL( $query );
1677
1678
		# Expand the url to make it a full url. Note that getLocalURL has the
1679
		# potential to output full urls for a variety of reasons, so we use
1680
		# wfExpandUrl instead of simply prepending $wgServer
1681
		$url = wfExpandUrl( $url, $proto );
1682
1683
		# Finally, add the fragment.
1684
		$url .= $this->getFragmentForURL();
1685
1686
		Hooks::run( 'GetFullURL', [ &$this, &$url, $query ] );
1687
		return $url;
1688
	}
1689
1690
	/**
1691
	 * Get a URL with no fragment or server name (relative URL) from a Title object.
1692
	 * If this page is generated with action=render, however,
1693
	 * $wgServer is prepended to make an absolute URL.
1694
	 *
1695
	 * @see self::getFullURL to always get an absolute URL.
1696
	 * @see self::getLinkURL to always get a URL that's the simplest URL that will be
1697
	 *  valid to link, locally, to the current Title.
1698
	 * @see self::newFromText to produce a Title object.
1699
	 *
1700
	 * @param string|array $query An optional query string,
1701
	 *   not used for interwiki links. Can be specified as an associative array as well,
1702
	 *   e.g., array( 'action' => 'edit' ) (keys and values will be URL-escaped).
1703
	 *   Some query patterns will trigger various shorturl path replacements.
1704
	 * @param array $query2 An optional secondary query array. This one MUST
1705
	 *   be an array. If a string is passed it will be interpreted as a deprecated
1706
	 *   variant argument and urlencoded into a variant= argument.
1707
	 *   This second query argument will be added to the $query
1708
	 *   The second parameter is deprecated since 1.19. Pass it as a key,value
1709
	 *   pair in the first parameter array instead.
1710
	 *
1711
	 * @return string String of the URL.
1712
	 */
1713
	public function getLocalURL( $query = '', $query2 = false ) {
1714
		global $wgArticlePath, $wgScript, $wgServer, $wgRequest;
1715
1716
		$query = self::fixUrlQueryArgs( $query, $query2 );
1717
1718
		$interwiki = self::getInterwikiLookup()->fetch( $this->mInterwiki );
1719
		if ( $interwiki ) {
1720
			$namespace = $this->getNsText();
1721
			if ( $namespace != '' ) {
1722
				# Can this actually happen? Interwikis shouldn't be parsed.
1723
				# Yes! It can in interwiki transclusion. But... it probably shouldn't.
1724
				$namespace .= ':';
1725
			}
1726
			$url = $interwiki->getURL( $namespace . $this->getDBkey() );
1727
			$url = wfAppendQuery( $url, $query );
1728
		} else {
1729
			$dbkey = wfUrlencode( $this->getPrefixedDBkey() );
1730
			if ( $query == '' ) {
1731
				$url = str_replace( '$1', $dbkey, $wgArticlePath );
1732
				Hooks::run( 'GetLocalURL::Article', [ &$this, &$url ] );
1733
			} else {
1734
				global $wgVariantArticlePath, $wgActionPaths, $wgContLang;
1735
				$url = false;
1736
				$matches = [];
1737
1738
				if ( !empty( $wgActionPaths )
1739
					&& preg_match( '/^(.*&|)action=([^&]*)(&(.*)|)$/', $query, $matches )
1740
				) {
1741
					$action = urldecode( $matches[2] );
1742
					if ( isset( $wgActionPaths[$action] ) ) {
1743
						$query = $matches[1];
1744
						if ( isset( $matches[4] ) ) {
1745
							$query .= $matches[4];
1746
						}
1747
						$url = str_replace( '$1', $dbkey, $wgActionPaths[$action] );
1748
						if ( $query != '' ) {
1749
							$url = wfAppendQuery( $url, $query );
1750
						}
1751
					}
1752
				}
1753
1754
				if ( $url === false
1755
					&& $wgVariantArticlePath
1756
					&& preg_match( '/^variant=([^&]*)$/', $query, $matches )
1757
					&& $this->getPageLanguage()->equals( $wgContLang )
1758
					&& $this->getPageLanguage()->hasVariants()
1759
				) {
1760
					$variant = urldecode( $matches[1] );
1761
					if ( $this->getPageLanguage()->hasVariant( $variant ) ) {
1762
						// Only do the variant replacement if the given variant is a valid
1763
						// variant for the page's language.
1764
						$url = str_replace( '$2', urlencode( $variant ), $wgVariantArticlePath );
1765
						$url = str_replace( '$1', $dbkey, $url );
1766
					}
1767
				}
1768
1769
				if ( $url === false ) {
1770
					if ( $query == '-' ) {
1771
						$query = '';
1772
					}
1773
					$url = "{$wgScript}?title={$dbkey}&{$query}";
1774
				}
1775
			}
1776
1777
			Hooks::run( 'GetLocalURL::Internal', [ &$this, &$url, $query ] );
1778
1779
			// @todo FIXME: This causes breakage in various places when we
1780
			// actually expected a local URL and end up with dupe prefixes.
1781
			if ( $wgRequest->getVal( 'action' ) == 'render' ) {
1782
				$url = $wgServer . $url;
1783
			}
1784
		}
1785
		Hooks::run( 'GetLocalURL', [ &$this, &$url, $query ] );
1786
		return $url;
1787
	}
1788
1789
	/**
1790
	 * Get a URL that's the simplest URL that will be valid to link, locally,
1791
	 * to the current Title.  It includes the fragment, but does not include
1792
	 * the server unless action=render is used (or the link is external).  If
1793
	 * there's a fragment but the prefixed text is empty, we just return a link
1794
	 * to the fragment.
1795
	 *
1796
	 * The result obviously should not be URL-escaped, but does need to be
1797
	 * HTML-escaped if it's being output in HTML.
1798
	 *
1799
	 * @param array $query
1800
	 * @param bool $query2
1801
	 * @param string|int|bool $proto A PROTO_* constant on how the URL should be expanded,
1802
	 *                               or false (default) for no expansion
1803
	 * @see self::getLocalURL for the arguments.
1804
	 * @return string The URL
1805
	 */
1806
	public function getLinkURL( $query = '', $query2 = false, $proto = false ) {
1807
		if ( $this->isExternal() || $proto !== false ) {
1808
			$ret = $this->getFullURL( $query, $query2, $proto );
1809
		} elseif ( $this->getPrefixedText() === '' && $this->hasFragment() ) {
1810
			$ret = $this->getFragmentForURL();
1811
		} else {
1812
			$ret = $this->getLocalURL( $query, $query2 ) . $this->getFragmentForURL();
1813
		}
1814
		return $ret;
1815
	}
1816
1817
	/**
1818
	 * Get the URL form for an internal link.
1819
	 * - Used in various CDN-related code, in case we have a different
1820
	 * internal hostname for the server from the exposed one.
1821
	 *
1822
	 * This uses $wgInternalServer to qualify the path, or $wgServer
1823
	 * if $wgInternalServer is not set. If the server variable used is
1824
	 * protocol-relative, the URL will be expanded to http://
1825
	 *
1826
	 * @see self::getLocalURL for the arguments.
1827
	 * @return string The URL
1828
	 */
1829
	public function getInternalURL( $query = '', $query2 = false ) {
1830
		global $wgInternalServer, $wgServer;
1831
		$query = self::fixUrlQueryArgs( $query, $query2 );
1832
		$server = $wgInternalServer !== false ? $wgInternalServer : $wgServer;
1833
		$url = wfExpandUrl( $server . $this->getLocalURL( $query ), PROTO_HTTP );
1834
		Hooks::run( 'GetInternalURL', [ &$this, &$url, $query ] );
1835
		return $url;
1836
	}
1837
1838
	/**
1839
	 * Get the URL for a canonical link, for use in things like IRC and
1840
	 * e-mail notifications. Uses $wgCanonicalServer and the
1841
	 * GetCanonicalURL hook.
1842
	 *
1843
	 * NOTE: Unlike getInternalURL(), the canonical URL includes the fragment
1844
	 *
1845
	 * @see self::getLocalURL for the arguments.
1846
	 * @return string The URL
1847
	 * @since 1.18
1848
	 */
1849
	public function getCanonicalURL( $query = '', $query2 = false ) {
1850
		$query = self::fixUrlQueryArgs( $query, $query2 );
1851
		$url = wfExpandUrl( $this->getLocalURL( $query ) . $this->getFragmentForURL(), PROTO_CANONICAL );
1852
		Hooks::run( 'GetCanonicalURL', [ &$this, &$url, $query ] );
1853
		return $url;
1854
	}
1855
1856
	/**
1857
	 * Get the edit URL for this Title
1858
	 *
1859
	 * @return string The URL, or a null string if this is an interwiki link
1860
	 */
1861
	public function getEditURL() {
1862
		if ( $this->isExternal() ) {
1863
			return '';
1864
		}
1865
		$s = $this->getLocalURL( 'action=edit' );
1866
1867
		return $s;
1868
	}
1869
1870
	/**
1871
	 * Can $user perform $action on this page?
1872
	 * This skips potentially expensive cascading permission checks
1873
	 * as well as avoids expensive error formatting
1874
	 *
1875
	 * Suitable for use for nonessential UI controls in common cases, but
1876
	 * _not_ for functional access control.
1877
	 *
1878
	 * May provide false positives, but should never provide a false negative.
1879
	 *
1880
	 * @param string $action Action that permission needs to be checked for
1881
	 * @param User $user User to check (since 1.19); $wgUser will be used if not provided.
1882
	 * @return bool
1883
	 */
1884
	public function quickUserCan( $action, $user = null ) {
1885
		return $this->userCan( $action, $user, false );
1886
	}
1887
1888
	/**
1889
	 * Can $user perform $action on this page?
1890
	 *
1891
	 * @param string $action Action that permission needs to be checked for
1892
	 * @param User $user User to check (since 1.19); $wgUser will be used if not
1893
	 *   provided.
1894
	 * @param string $rigor Same format as Title::getUserPermissionsErrors()
1895
	 * @return bool
1896
	 */
1897
	public function userCan( $action, $user = null, $rigor = 'secure' ) {
1898
		if ( !$user instanceof User ) {
1899
			global $wgUser;
1900
			$user = $wgUser;
1901
		}
1902
1903
		return !count( $this->getUserPermissionsErrorsInternal( $action, $user, $rigor, true ) );
1904
	}
1905
1906
	/**
1907
	 * Can $user perform $action on this page?
1908
	 *
1909
	 * @todo FIXME: This *does not* check throttles (User::pingLimiter()).
1910
	 *
1911
	 * @param string $action Action that permission needs to be checked for
1912
	 * @param User $user User to check
1913
	 * @param string $rigor One of (quick,full,secure)
1914
	 *   - quick  : does cheap permission checks from replica DBs (usable for GUI creation)
1915
	 *   - full   : does cheap and expensive checks possibly from a replica DB
1916
	 *   - secure : does cheap and expensive checks, using the master as needed
1917
	 * @param array $ignoreErrors Array of Strings Set this to a list of message keys
1918
	 *   whose corresponding errors may be ignored.
1919
	 * @return array Array of arrays of the arguments to wfMessage to explain permissions problems.
1920
	 */
1921
	public function getUserPermissionsErrors(
1922
		$action, $user, $rigor = 'secure', $ignoreErrors = []
1923
	) {
1924
		$errors = $this->getUserPermissionsErrorsInternal( $action, $user, $rigor );
1925
1926
		// Remove the errors being ignored.
1927
		foreach ( $errors as $index => $error ) {
1928
			$errKey = is_array( $error ) ? $error[0] : $error;
1929
1930
			if ( in_array( $errKey, $ignoreErrors ) ) {
1931
				unset( $errors[$index] );
1932
			}
1933
			if ( $errKey instanceof MessageSpecifier && in_array( $errKey->getKey(), $ignoreErrors ) ) {
1934
				unset( $errors[$index] );
1935
			}
1936
		}
1937
1938
		return $errors;
1939
	}
1940
1941
	/**
1942
	 * Permissions checks that fail most often, and which are easiest to test.
1943
	 *
1944
	 * @param string $action The action to check
1945
	 * @param User $user User to check
1946
	 * @param array $errors List of current errors
1947
	 * @param string $rigor Same format as Title::getUserPermissionsErrors()
1948
	 * @param bool $short Short circuit on first error
1949
	 *
1950
	 * @return array List of errors
1951
	 */
1952
	private function checkQuickPermissions( $action, $user, $errors, $rigor, $short ) {
1953
		if ( !Hooks::run( 'TitleQuickPermissions',
1954
			[ $this, $user, $action, &$errors, ( $rigor !== 'quick' ), $short ] )
1955
		) {
1956
			return $errors;
1957
		}
1958
1959
		if ( $action == 'create' ) {
1960
			if (
1961
				( $this->isTalkPage() && !$user->isAllowed( 'createtalk' ) ) ||
1962
				( !$this->isTalkPage() && !$user->isAllowed( 'createpage' ) )
1963
			) {
1964
				$errors[] = $user->isAnon() ? [ 'nocreatetext' ] : [ 'nocreate-loggedin' ];
1965
			}
1966
		} elseif ( $action == 'move' ) {
1967 View Code Duplication
			if ( !$user->isAllowed( 'move-rootuserpages' )
1968
					&& $this->mNamespace == NS_USER && !$this->isSubpage() ) {
1969
				// Show user page-specific message only if the user can move other pages
1970
				$errors[] = [ 'cant-move-user-page' ];
1971
			}
1972
1973
			// Check if user is allowed to move files if it's a file
1974
			if ( $this->mNamespace == NS_FILE && !$user->isAllowed( 'movefile' ) ) {
1975
				$errors[] = [ 'movenotallowedfile' ];
1976
			}
1977
1978
			// Check if user is allowed to move category pages if it's a category page
1979
			if ( $this->mNamespace == NS_CATEGORY && !$user->isAllowed( 'move-categorypages' ) ) {
1980
				$errors[] = [ 'cant-move-category-page' ];
1981
			}
1982
1983
			if ( !$user->isAllowed( 'move' ) ) {
1984
				// User can't move anything
1985
				$userCanMove = User::groupHasPermission( 'user', 'move' );
1986
				$autoconfirmedCanMove = User::groupHasPermission( 'autoconfirmed', 'move' );
1987
				if ( $user->isAnon() && ( $userCanMove || $autoconfirmedCanMove ) ) {
1988
					// custom message if logged-in users without any special rights can move
1989
					$errors[] = [ 'movenologintext' ];
1990
				} else {
1991
					$errors[] = [ 'movenotallowed' ];
1992
				}
1993
			}
1994
		} elseif ( $action == 'move-target' ) {
1995
			if ( !$user->isAllowed( 'move' ) ) {
1996
				// User can't move anything
1997
				$errors[] = [ 'movenotallowed' ];
1998 View Code Duplication
			} elseif ( !$user->isAllowed( 'move-rootuserpages' )
1999
					&& $this->mNamespace == NS_USER && !$this->isSubpage() ) {
2000
				// Show user page-specific message only if the user can move other pages
2001
				$errors[] = [ 'cant-move-to-user-page' ];
2002
			} elseif ( !$user->isAllowed( 'move-categorypages' )
2003
					&& $this->mNamespace == NS_CATEGORY ) {
2004
				// Show category page-specific message only if the user can move other pages
2005
				$errors[] = [ 'cant-move-to-category-page' ];
2006
			}
2007
		} elseif ( !$user->isAllowed( $action ) ) {
2008
			$errors[] = $this->missingPermissionError( $action, $short );
2009
		}
2010
2011
		return $errors;
2012
	}
2013
2014
	/**
2015
	 * Add the resulting error code to the errors array
2016
	 *
2017
	 * @param array $errors List of current errors
2018
	 * @param array $result Result of errors
2019
	 *
2020
	 * @return array List of errors
2021
	 */
2022
	private function resultToError( $errors, $result ) {
2023
		if ( is_array( $result ) && count( $result ) && !is_array( $result[0] ) ) {
2024
			// A single array representing an error
2025
			$errors[] = $result;
2026
		} elseif ( is_array( $result ) && is_array( $result[0] ) ) {
2027
			// A nested array representing multiple errors
2028
			$errors = array_merge( $errors, $result );
2029
		} elseif ( $result !== '' && is_string( $result ) ) {
2030
			// A string representing a message-id
2031
			$errors[] = [ $result ];
2032
		} elseif ( $result instanceof MessageSpecifier ) {
2033
			// A message specifier representing an error
2034
			$errors[] = [ $result ];
2035
		} elseif ( $result === false ) {
2036
			// a generic "We don't want them to do that"
2037
			$errors[] = [ 'badaccess-group0' ];
2038
		}
2039
		return $errors;
2040
	}
2041
2042
	/**
2043
	 * Check various permission hooks
2044
	 *
2045
	 * @param string $action The action to check
2046
	 * @param User $user User to check
2047
	 * @param array $errors List of current errors
2048
	 * @param string $rigor Same format as Title::getUserPermissionsErrors()
2049
	 * @param bool $short Short circuit on first error
2050
	 *
2051
	 * @return array List of errors
2052
	 */
2053
	private function checkPermissionHooks( $action, $user, $errors, $rigor, $short ) {
2054
		// Use getUserPermissionsErrors instead
2055
		$result = '';
2056
		if ( !Hooks::run( 'userCan', [ &$this, &$user, $action, &$result ] ) ) {
2057
			return $result ? [] : [ [ 'badaccess-group0' ] ];
2058
		}
2059
		// Check getUserPermissionsErrors hook
2060
		if ( !Hooks::run( 'getUserPermissionsErrors', [ &$this, &$user, $action, &$result ] ) ) {
2061
			$errors = $this->resultToError( $errors, $result );
2062
		}
2063
		// Check getUserPermissionsErrorsExpensive hook
2064
		if (
2065
			$rigor !== 'quick'
2066
			&& !( $short && count( $errors ) > 0 )
2067
			&& !Hooks::run( 'getUserPermissionsErrorsExpensive', [ &$this, &$user, $action, &$result ] )
2068
		) {
2069
			$errors = $this->resultToError( $errors, $result );
2070
		}
2071
2072
		return $errors;
2073
	}
2074
2075
	/**
2076
	 * Check permissions on special pages & namespaces
2077
	 *
2078
	 * @param string $action The action to check
2079
	 * @param User $user User to check
2080
	 * @param array $errors List of current errors
2081
	 * @param string $rigor Same format as Title::getUserPermissionsErrors()
2082
	 * @param bool $short Short circuit on first error
2083
	 *
2084
	 * @return array List of errors
2085
	 */
2086
	private function checkSpecialsAndNSPermissions( $action, $user, $errors, $rigor, $short ) {
2087
		# Only 'createaccount' can be performed on special pages,
2088
		# which don't actually exist in the DB.
2089
		if ( NS_SPECIAL == $this->mNamespace && $action !== 'createaccount' ) {
2090
			$errors[] = [ 'ns-specialprotected' ];
2091
		}
2092
2093
		# Check $wgNamespaceProtection for restricted namespaces
2094
		if ( $this->isNamespaceProtected( $user ) ) {
2095
			$ns = $this->mNamespace == NS_MAIN ?
2096
				wfMessage( 'nstab-main' )->text() : $this->getNsText();
2097
			$errors[] = $this->mNamespace == NS_MEDIAWIKI ?
2098
				[ 'protectedinterface', $action ] : [ 'namespaceprotected', $ns, $action ];
2099
		}
2100
2101
		return $errors;
2102
	}
2103
2104
	/**
2105
	 * Check CSS/JS sub-page permissions
2106
	 *
2107
	 * @param string $action The action to check
2108
	 * @param User $user User to check
2109
	 * @param array $errors List of current errors
2110
	 * @param string $rigor Same format as Title::getUserPermissionsErrors()
2111
	 * @param bool $short Short circuit on first error
2112
	 *
2113
	 * @return array List of errors
2114
	 */
2115
	private function checkCSSandJSPermissions( $action, $user, $errors, $rigor, $short ) {
2116
		# Protect css/js subpages of user pages
2117
		# XXX: this might be better using restrictions
2118
		# XXX: right 'editusercssjs' is deprecated, for backward compatibility only
2119
		if ( $action != 'patrol' && !$user->isAllowed( 'editusercssjs' ) ) {
2120
			if ( preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $this->mTextform ) ) {
2121 View Code Duplication
				if ( $this->isCssSubpage() && !$user->isAllowedAny( 'editmyusercss', 'editusercss' ) ) {
2122
					$errors[] = [ 'mycustomcssprotected', $action ];
2123
				} elseif ( $this->isJsSubpage() && !$user->isAllowedAny( 'editmyuserjs', 'edituserjs' ) ) {
2124
					$errors[] = [ 'mycustomjsprotected', $action ];
2125
				}
2126 View Code Duplication
			} else {
2127
				if ( $this->isCssSubpage() && !$user->isAllowed( 'editusercss' ) ) {
2128
					$errors[] = [ 'customcssprotected', $action ];
2129
				} elseif ( $this->isJsSubpage() && !$user->isAllowed( 'edituserjs' ) ) {
2130
					$errors[] = [ 'customjsprotected', $action ];
2131
				}
2132
			}
2133
		}
2134
2135
		return $errors;
2136
	}
2137
2138
	/**
2139
	 * Check against page_restrictions table requirements on this
2140
	 * page. The user must possess all required rights for this
2141
	 * action.
2142
	 *
2143
	 * @param string $action The action to check
2144
	 * @param User $user User to check
2145
	 * @param array $errors List of current errors
2146
	 * @param string $rigor Same format as Title::getUserPermissionsErrors()
2147
	 * @param bool $short Short circuit on first error
2148
	 *
2149
	 * @return array List of errors
2150
	 */
2151
	private function checkPageRestrictions( $action, $user, $errors, $rigor, $short ) {
2152
		foreach ( $this->getRestrictions( $action ) as $right ) {
2153
			// Backwards compatibility, rewrite sysop -> editprotected
2154
			if ( $right == 'sysop' ) {
2155
				$right = 'editprotected';
2156
			}
2157
			// Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
2158
			if ( $right == 'autoconfirmed' ) {
2159
				$right = 'editsemiprotected';
2160
			}
2161
			if ( $right == '' ) {
2162
				continue;
2163
			}
2164
			if ( !$user->isAllowed( $right ) ) {
2165
				$errors[] = [ 'protectedpagetext', $right, $action ];
2166
			} elseif ( $this->mCascadeRestriction && !$user->isAllowed( 'protect' ) ) {
2167
				$errors[] = [ 'protectedpagetext', 'protect', $action ];
2168
			}
2169
		}
2170
2171
		return $errors;
2172
	}
2173
2174
	/**
2175
	 * Check restrictions on cascading pages.
2176
	 *
2177
	 * @param string $action The action to check
2178
	 * @param User $user User to check
2179
	 * @param array $errors List of current errors
2180
	 * @param string $rigor Same format as Title::getUserPermissionsErrors()
2181
	 * @param bool $short Short circuit on first error
2182
	 *
2183
	 * @return array List of errors
2184
	 */
2185
	private function checkCascadingSourcesRestrictions( $action, $user, $errors, $rigor, $short ) {
2186
		if ( $rigor !== 'quick' && !$this->isCssJsSubpage() ) {
2187
			# We /could/ use the protection level on the source page, but it's
2188
			# fairly ugly as we have to establish a precedence hierarchy for pages
2189
			# included by multiple cascade-protected pages. So just restrict
2190
			# it to people with 'protect' permission, as they could remove the
2191
			# protection anyway.
2192
			list( $cascadingSources, $restrictions ) = $this->getCascadeProtectionSources();
2193
			# Cascading protection depends on more than this page...
2194
			# Several cascading protected pages may include this page...
2195
			# Check each cascading level
2196
			# This is only for protection restrictions, not for all actions
2197
			if ( isset( $restrictions[$action] ) ) {
2198
				foreach ( $restrictions[$action] as $right ) {
2199
					// Backwards compatibility, rewrite sysop -> editprotected
2200
					if ( $right == 'sysop' ) {
2201
						$right = 'editprotected';
2202
					}
2203
					// Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
2204
					if ( $right == 'autoconfirmed' ) {
2205
						$right = 'editsemiprotected';
2206
					}
2207
					if ( $right != '' && !$user->isAllowedAll( 'protect', $right ) ) {
2208
						$pages = '';
2209
						foreach ( $cascadingSources as $page ) {
2210
							$pages .= '* [[:' . $page->getPrefixedText() . "]]\n";
2211
						}
2212
						$errors[] = [ 'cascadeprotected', count( $cascadingSources ), $pages, $action ];
2213
					}
2214
				}
2215
			}
2216
		}
2217
2218
		return $errors;
2219
	}
2220
2221
	/**
2222
	 * Check action permissions not already checked in checkQuickPermissions
2223
	 *
2224
	 * @param string $action The action to check
2225
	 * @param User $user User to check
2226
	 * @param array $errors List of current errors
2227
	 * @param string $rigor Same format as Title::getUserPermissionsErrors()
2228
	 * @param bool $short Short circuit on first error
2229
	 *
2230
	 * @return array List of errors
2231
	 */
2232
	private function checkActionPermissions( $action, $user, $errors, $rigor, $short ) {
2233
		global $wgDeleteRevisionsLimit, $wgLang;
2234
2235
		if ( $action == 'protect' ) {
2236
			if ( count( $this->getUserPermissionsErrorsInternal( 'edit', $user, $rigor, true ) ) ) {
2237
				// If they can't edit, they shouldn't protect.
2238
				$errors[] = [ 'protect-cantedit' ];
2239
			}
2240
		} elseif ( $action == 'create' ) {
2241
			$title_protection = $this->getTitleProtection();
2242
			if ( $title_protection ) {
2243
				if ( $title_protection['permission'] == ''
2244
					|| !$user->isAllowed( $title_protection['permission'] )
2245
				) {
2246
					$errors[] = [
2247
						'titleprotected',
2248
						User::whoIs( $title_protection['user'] ),
2249
						$title_protection['reason']
2250
					];
2251
				}
2252
			}
2253
		} elseif ( $action == 'move' ) {
2254
			// Check for immobile pages
2255
			if ( !MWNamespace::isMovable( $this->mNamespace ) ) {
2256
				// Specific message for this case
2257
				$errors[] = [ 'immobile-source-namespace', $this->getNsText() ];
2258
			} elseif ( !$this->isMovable() ) {
2259
				// Less specific message for rarer cases
2260
				$errors[] = [ 'immobile-source-page' ];
2261
			}
2262
		} elseif ( $action == 'move-target' ) {
2263
			if ( !MWNamespace::isMovable( $this->mNamespace ) ) {
2264
				$errors[] = [ 'immobile-target-namespace', $this->getNsText() ];
2265
			} elseif ( !$this->isMovable() ) {
2266
				$errors[] = [ 'immobile-target-page' ];
2267
			}
2268
		} elseif ( $action == 'delete' ) {
2269
			$tempErrors = $this->checkPageRestrictions( 'edit', $user, [], $rigor, true );
2270
			if ( !$tempErrors ) {
2271
				$tempErrors = $this->checkCascadingSourcesRestrictions( 'edit',
2272
					$user, $tempErrors, $rigor, true );
2273
			}
2274
			if ( $tempErrors ) {
2275
				// If protection keeps them from editing, they shouldn't be able to delete.
2276
				$errors[] = [ 'deleteprotected' ];
2277
			}
2278
			if ( $rigor !== 'quick' && $wgDeleteRevisionsLimit
2279
				&& !$this->userCan( 'bigdelete', $user ) && $this->isBigDeletion()
2280
			) {
2281
				$errors[] = [ 'delete-toobig', $wgLang->formatNum( $wgDeleteRevisionsLimit ) ];
2282
			}
2283
		}
2284
		return $errors;
2285
	}
2286
2287
	/**
2288
	 * Check that the user isn't blocked from editing.
2289
	 *
2290
	 * @param string $action The action to check
2291
	 * @param User $user User to check
2292
	 * @param array $errors List of current errors
2293
	 * @param string $rigor Same format as Title::getUserPermissionsErrors()
2294
	 * @param bool $short Short circuit on first error
2295
	 *
2296
	 * @return array List of errors
2297
	 */
2298
	private function checkUserBlock( $action, $user, $errors, $rigor, $short ) {
2299
		global $wgEmailConfirmToEdit, $wgBlockDisablesLogin;
2300
		// Account creation blocks handled at userlogin.
2301
		// Unblocking handled in SpecialUnblock
2302
		if ( $rigor === 'quick' || in_array( $action, [ 'createaccount', 'unblock' ] ) ) {
2303
			return $errors;
2304
		}
2305
2306
		// Optimize for a very common case
2307
		if ( $action === 'read' && !$wgBlockDisablesLogin ) {
2308
			return $errors;
2309
		}
2310
2311
		if ( $wgEmailConfirmToEdit && !$user->isEmailConfirmed() ) {
2312
			$errors[] = [ 'confirmedittext' ];
2313
		}
2314
2315
		$useSlave = ( $rigor !== 'secure' );
2316
		if ( ( $action == 'edit' || $action == 'create' )
2317
			&& !$user->isBlockedFrom( $this, $useSlave )
2318
		) {
2319
			// Don't block the user from editing their own talk page unless they've been
2320
			// explicitly blocked from that too.
2321
		} elseif ( $user->isBlocked() && $user->getBlock()->prevents( $action ) !== false ) {
2322
			// @todo FIXME: Pass the relevant context into this function.
2323
			$errors[] = $user->getBlock()->getPermissionsError( RequestContext::getMain() );
2324
		}
2325
2326
		return $errors;
2327
	}
2328
2329
	/**
2330
	 * Check that the user is allowed to read this page.
2331
	 *
2332
	 * @param string $action The action to check
2333
	 * @param User $user User to check
2334
	 * @param array $errors List of current errors
2335
	 * @param string $rigor Same format as Title::getUserPermissionsErrors()
2336
	 * @param bool $short Short circuit on first error
2337
	 *
2338
	 * @return array List of errors
2339
	 */
2340
	private function checkReadPermissions( $action, $user, $errors, $rigor, $short ) {
2341
		global $wgWhitelistRead, $wgWhitelistReadRegexp;
2342
2343
		$whitelisted = false;
2344
		if ( User::isEveryoneAllowed( 'read' ) ) {
2345
			# Shortcut for public wikis, allows skipping quite a bit of code
2346
			$whitelisted = true;
2347
		} elseif ( $user->isAllowed( 'read' ) ) {
2348
			# If the user is allowed to read pages, he is allowed to read all pages
2349
			$whitelisted = true;
2350
		} elseif ( $this->isSpecial( 'Userlogin' )
2351
			|| $this->isSpecial( 'PasswordReset' )
2352
			|| $this->isSpecial( 'Userlogout' )
2353
		) {
2354
			# Always grant access to the login page.
2355
			# Even anons need to be able to log in.
2356
			$whitelisted = true;
2357
		} elseif ( is_array( $wgWhitelistRead ) && count( $wgWhitelistRead ) ) {
2358
			# Time to check the whitelist
2359
			# Only do these checks is there's something to check against
2360
			$name = $this->getPrefixedText();
2361
			$dbName = $this->getPrefixedDBkey();
2362
2363
			// Check for explicit whitelisting with and without underscores
2364
			if ( in_array( $name, $wgWhitelistRead, true ) || in_array( $dbName, $wgWhitelistRead, true ) ) {
2365
				$whitelisted = true;
2366
			} elseif ( $this->getNamespace() == NS_MAIN ) {
2367
				# Old settings might have the title prefixed with
2368
				# a colon for main-namespace pages
2369
				if ( in_array( ':' . $name, $wgWhitelistRead ) ) {
2370
					$whitelisted = true;
2371
				}
2372
			} elseif ( $this->isSpecialPage() ) {
2373
				# If it's a special page, ditch the subpage bit and check again
2374
				$name = $this->getDBkey();
2375
				list( $name, /* $subpage */ ) = SpecialPageFactory::resolveAlias( $name );
2376
				if ( $name ) {
2377
					$pure = SpecialPage::getTitleFor( $name )->getPrefixedText();
2378
					if ( in_array( $pure, $wgWhitelistRead, true ) ) {
2379
						$whitelisted = true;
2380
					}
2381
				}
2382
			}
2383
		}
2384
2385
		if ( !$whitelisted && is_array( $wgWhitelistReadRegexp ) && !empty( $wgWhitelistReadRegexp ) ) {
2386
			$name = $this->getPrefixedText();
2387
			// Check for regex whitelisting
2388
			foreach ( $wgWhitelistReadRegexp as $listItem ) {
2389
				if ( preg_match( $listItem, $name ) ) {
2390
					$whitelisted = true;
2391
					break;
2392
				}
2393
			}
2394
		}
2395
2396
		if ( !$whitelisted ) {
2397
			# If the title is not whitelisted, give extensions a chance to do so...
2398
			Hooks::run( 'TitleReadWhitelist', [ $this, $user, &$whitelisted ] );
2399
			if ( !$whitelisted ) {
2400
				$errors[] = $this->missingPermissionError( $action, $short );
2401
			}
2402
		}
2403
2404
		return $errors;
2405
	}
2406
2407
	/**
2408
	 * Get a description array when the user doesn't have the right to perform
2409
	 * $action (i.e. when User::isAllowed() returns false)
2410
	 *
2411
	 * @param string $action The action to check
2412
	 * @param bool $short Short circuit on first error
2413
	 * @return array List of errors
2414
	 */
2415
	private function missingPermissionError( $action, $short ) {
2416
		// We avoid expensive display logic for quickUserCan's and such
2417
		if ( $short ) {
2418
			return [ 'badaccess-group0' ];
2419
		}
2420
2421
		$groups = array_map( [ 'User', 'makeGroupLinkWiki' ],
2422
			User::getGroupsWithPermission( $action ) );
2423
2424
		if ( count( $groups ) ) {
2425
			global $wgLang;
2426
			return [
2427
				'badaccess-groups',
2428
				$wgLang->commaList( $groups ),
2429
				count( $groups )
2430
			];
2431
		} else {
2432
			return [ 'badaccess-group0' ];
2433
		}
2434
	}
2435
2436
	/**
2437
	 * Can $user perform $action on this page? This is an internal function,
2438
	 * with multiple levels of checks depending on performance needs; see $rigor below.
2439
	 * It does not check wfReadOnly().
2440
	 *
2441
	 * @param string $action Action that permission needs to be checked for
2442
	 * @param User $user User to check
2443
	 * @param string $rigor One of (quick,full,secure)
2444
	 *   - quick  : does cheap permission checks from replica DBs (usable for GUI creation)
2445
	 *   - full   : does cheap and expensive checks possibly from a replica DB
2446
	 *   - secure : does cheap and expensive checks, using the master as needed
2447
	 * @param bool $short Set this to true to stop after the first permission error.
2448
	 * @return array Array of arrays of the arguments to wfMessage to explain permissions problems.
2449
	 */
2450
	protected function getUserPermissionsErrorsInternal(
2451
		$action, $user, $rigor = 'secure', $short = false
2452
	) {
2453
		if ( $rigor === true ) {
2454
			$rigor = 'secure'; // b/c
2455
		} elseif ( $rigor === false ) {
2456
			$rigor = 'quick'; // b/c
2457
		} elseif ( !in_array( $rigor, [ 'quick', 'full', 'secure' ] ) ) {
2458
			throw new Exception( "Invalid rigor parameter '$rigor'." );
2459
		}
2460
2461
		# Read has special handling
2462
		if ( $action == 'read' ) {
2463
			$checks = [
2464
				'checkPermissionHooks',
2465
				'checkReadPermissions',
2466
				'checkUserBlock', // for wgBlockDisablesLogin
2467
			];
2468
		# Don't call checkSpecialsAndNSPermissions or checkCSSandJSPermissions
2469
		# here as it will lead to duplicate error messages. This is okay to do
2470
		# since anywhere that checks for create will also check for edit, and
2471
		# those checks are called for edit.
2472
		} elseif ( $action == 'create' ) {
2473
			$checks = [
2474
				'checkQuickPermissions',
2475
				'checkPermissionHooks',
2476
				'checkPageRestrictions',
2477
				'checkCascadingSourcesRestrictions',
2478
				'checkActionPermissions',
2479
				'checkUserBlock'
2480
			];
2481
		} else {
2482
			$checks = [
2483
				'checkQuickPermissions',
2484
				'checkPermissionHooks',
2485
				'checkSpecialsAndNSPermissions',
2486
				'checkCSSandJSPermissions',
2487
				'checkPageRestrictions',
2488
				'checkCascadingSourcesRestrictions',
2489
				'checkActionPermissions',
2490
				'checkUserBlock'
2491
			];
2492
		}
2493
2494
		$errors = [];
2495
		while ( count( $checks ) > 0 &&
2496
				!( $short && count( $errors ) > 0 ) ) {
2497
			$method = array_shift( $checks );
2498
			$errors = $this->$method( $action, $user, $errors, $rigor, $short );
2499
		}
2500
2501
		return $errors;
2502
	}
2503
2504
	/**
2505
	 * Get a filtered list of all restriction types supported by this wiki.
2506
	 * @param bool $exists True to get all restriction types that apply to
2507
	 * titles that do exist, False for all restriction types that apply to
2508
	 * titles that do not exist
2509
	 * @return array
2510
	 */
2511
	public static function getFilteredRestrictionTypes( $exists = true ) {
2512
		global $wgRestrictionTypes;
2513
		$types = $wgRestrictionTypes;
2514
		if ( $exists ) {
2515
			# Remove the create restriction for existing titles
2516
			$types = array_diff( $types, [ 'create' ] );
2517
		} else {
2518
			# Only the create and upload restrictions apply to non-existing titles
2519
			$types = array_intersect( $types, [ 'create', 'upload' ] );
2520
		}
2521
		return $types;
2522
	}
2523
2524
	/**
2525
	 * Returns restriction types for the current Title
2526
	 *
2527
	 * @return array Applicable restriction types
2528
	 */
2529
	public function getRestrictionTypes() {
2530
		if ( $this->isSpecialPage() ) {
2531
			return [];
2532
		}
2533
2534
		$types = self::getFilteredRestrictionTypes( $this->exists() );
2535
2536
		if ( $this->getNamespace() != NS_FILE ) {
2537
			# Remove the upload restriction for non-file titles
2538
			$types = array_diff( $types, [ 'upload' ] );
2539
		}
2540
2541
		Hooks::run( 'TitleGetRestrictionTypes', [ $this, &$types ] );
2542
2543
		wfDebug( __METHOD__ . ': applicable restrictions to [[' .
2544
			$this->getPrefixedText() . ']] are {' . implode( ',', $types ) . "}\n" );
2545
2546
		return $types;
2547
	}
2548
2549
	/**
2550
	 * Is this title subject to title protection?
2551
	 * Title protection is the one applied against creation of such title.
2552
	 *
2553
	 * @return array|bool An associative array representing any existent title
2554
	 *   protection, or false if there's none.
2555
	 */
2556
	public function getTitleProtection() {
2557
		// Can't protect pages in special namespaces
2558
		if ( $this->getNamespace() < 0 ) {
2559
			return false;
2560
		}
2561
2562
		// Can't protect pages that exist.
2563
		if ( $this->exists() ) {
2564
			return false;
2565
		}
2566
2567
		if ( $this->mTitleProtection === null ) {
2568
			$dbr = wfGetDB( DB_REPLICA );
2569
			$res = $dbr->select(
2570
				'protected_titles',
2571
				[
2572
					'user' => 'pt_user',
2573
					'reason' => 'pt_reason',
2574
					'expiry' => 'pt_expiry',
2575
					'permission' => 'pt_create_perm'
2576
				],
2577
				[ 'pt_namespace' => $this->getNamespace(), 'pt_title' => $this->getDBkey() ],
2578
				__METHOD__
2579
			);
2580
2581
			// fetchRow returns false if there are no rows.
2582
			$row = $dbr->fetchRow( $res );
2583
			if ( $row ) {
2584
				if ( $row['permission'] == 'sysop' ) {
2585
					$row['permission'] = 'editprotected'; // B/C
2586
				}
2587
				if ( $row['permission'] == 'autoconfirmed' ) {
2588
					$row['permission'] = 'editsemiprotected'; // B/C
2589
				}
2590
				$row['expiry'] = $dbr->decodeExpiry( $row['expiry'] );
2591
			}
2592
			$this->mTitleProtection = $row;
2593
		}
2594
		return $this->mTitleProtection;
2595
	}
2596
2597
	/**
2598
	 * Remove any title protection due to page existing
2599
	 */
2600
	public function deleteTitleProtection() {
2601
		$dbw = wfGetDB( DB_MASTER );
2602
2603
		$dbw->delete(
2604
			'protected_titles',
2605
			[ 'pt_namespace' => $this->getNamespace(), 'pt_title' => $this->getDBkey() ],
2606
			__METHOD__
2607
		);
2608
		$this->mTitleProtection = false;
2609
	}
2610
2611
	/**
2612
	 * Is this page "semi-protected" - the *only* protection levels are listed
2613
	 * in $wgSemiprotectedRestrictionLevels?
2614
	 *
2615
	 * @param string $action Action to check (default: edit)
2616
	 * @return bool
2617
	 */
2618
	public function isSemiProtected( $action = 'edit' ) {
2619
		global $wgSemiprotectedRestrictionLevels;
2620
2621
		$restrictions = $this->getRestrictions( $action );
2622
		$semi = $wgSemiprotectedRestrictionLevels;
2623
		if ( !$restrictions || !$semi ) {
2624
			// Not protected, or all protection is full protection
2625
			return false;
2626
		}
2627
2628
		// Remap autoconfirmed to editsemiprotected for BC
2629
		foreach ( array_keys( $semi, 'autoconfirmed' ) as $key ) {
2630
			$semi[$key] = 'editsemiprotected';
2631
		}
2632
		foreach ( array_keys( $restrictions, 'autoconfirmed' ) as $key ) {
2633
			$restrictions[$key] = 'editsemiprotected';
2634
		}
2635
2636
		return !array_diff( $restrictions, $semi );
2637
	}
2638
2639
	/**
2640
	 * Does the title correspond to a protected article?
2641
	 *
2642
	 * @param string $action The action the page is protected from,
2643
	 * by default checks all actions.
2644
	 * @return bool
2645
	 */
2646
	public function isProtected( $action = '' ) {
2647
		global $wgRestrictionLevels;
2648
2649
		$restrictionTypes = $this->getRestrictionTypes();
2650
2651
		# Special pages have inherent protection
2652
		if ( $this->isSpecialPage() ) {
2653
			return true;
2654
		}
2655
2656
		# Check regular protection levels
2657
		foreach ( $restrictionTypes as $type ) {
2658
			if ( $action == $type || $action == '' ) {
2659
				$r = $this->getRestrictions( $type );
2660
				foreach ( $wgRestrictionLevels as $level ) {
2661
					if ( in_array( $level, $r ) && $level != '' ) {
2662
						return true;
2663
					}
2664
				}
2665
			}
2666
		}
2667
2668
		return false;
2669
	}
2670
2671
	/**
2672
	 * Determines if $user is unable to edit this page because it has been protected
2673
	 * by $wgNamespaceProtection.
2674
	 *
2675
	 * @param User $user User object to check permissions
2676
	 * @return bool
2677
	 */
2678
	public function isNamespaceProtected( User $user ) {
2679
		global $wgNamespaceProtection;
2680
2681
		if ( isset( $wgNamespaceProtection[$this->mNamespace] ) ) {
2682
			foreach ( (array)$wgNamespaceProtection[$this->mNamespace] as $right ) {
2683
				if ( $right != '' && !$user->isAllowed( $right ) ) {
2684
					return true;
2685
				}
2686
			}
2687
		}
2688
		return false;
2689
	}
2690
2691
	/**
2692
	 * Cascading protection: Return true if cascading restrictions apply to this page, false if not.
2693
	 *
2694
	 * @return bool If the page is subject to cascading restrictions.
2695
	 */
2696
	public function isCascadeProtected() {
2697
		list( $sources, /* $restrictions */ ) = $this->getCascadeProtectionSources( false );
2698
		return ( $sources > 0 );
2699
	}
2700
2701
	/**
2702
	 * Determines whether cascading protection sources have already been loaded from
2703
	 * the database.
2704
	 *
2705
	 * @param bool $getPages True to check if the pages are loaded, or false to check
2706
	 * if the status is loaded.
2707
	 * @return bool Whether or not the specified information has been loaded
2708
	 * @since 1.23
2709
	 */
2710
	public function areCascadeProtectionSourcesLoaded( $getPages = true ) {
2711
		return $getPages ? $this->mCascadeSources !== null : $this->mHasCascadingRestrictions !== null;
2712
	}
2713
2714
	/**
2715
	 * Cascading protection: Get the source of any cascading restrictions on this page.
2716
	 *
2717
	 * @param bool $getPages Whether or not to retrieve the actual pages
2718
	 *        that the restrictions have come from and the actual restrictions
2719
	 *        themselves.
2720
	 * @return array Two elements: First is an array of Title objects of the
2721
	 *        pages from which cascading restrictions have come, false for
2722
	 *        none, or true if such restrictions exist but $getPages was not
2723
	 *        set. Second is an array like that returned by
2724
	 *        Title::getAllRestrictions(), or an empty array if $getPages is
2725
	 *        false.
2726
	 */
2727
	public function getCascadeProtectionSources( $getPages = true ) {
2728
		$pagerestrictions = [];
2729
2730
		if ( $this->mCascadeSources !== null && $getPages ) {
2731
			return [ $this->mCascadeSources, $this->mCascadingRestrictions ];
2732
		} elseif ( $this->mHasCascadingRestrictions !== null && !$getPages ) {
2733
			return [ $this->mHasCascadingRestrictions, $pagerestrictions ];
2734
		}
2735
2736
		$dbr = wfGetDB( DB_REPLICA );
2737
2738
		if ( $this->getNamespace() == NS_FILE ) {
2739
			$tables = [ 'imagelinks', 'page_restrictions' ];
2740
			$where_clauses = [
2741
				'il_to' => $this->getDBkey(),
2742
				'il_from=pr_page',
2743
				'pr_cascade' => 1
2744
			];
2745
		} else {
2746
			$tables = [ 'templatelinks', 'page_restrictions' ];
2747
			$where_clauses = [
2748
				'tl_namespace' => $this->getNamespace(),
2749
				'tl_title' => $this->getDBkey(),
2750
				'tl_from=pr_page',
2751
				'pr_cascade' => 1
2752
			];
2753
		}
2754
2755
		if ( $getPages ) {
2756
			$cols = [ 'pr_page', 'page_namespace', 'page_title',
2757
				'pr_expiry', 'pr_type', 'pr_level' ];
2758
			$where_clauses[] = 'page_id=pr_page';
2759
			$tables[] = 'page';
2760
		} else {
2761
			$cols = [ 'pr_expiry' ];
2762
		}
2763
2764
		$res = $dbr->select( $tables, $cols, $where_clauses, __METHOD__ );
2765
2766
		$sources = $getPages ? [] : false;
2767
		$now = wfTimestampNow();
2768
2769
		foreach ( $res as $row ) {
2770
			$expiry = $dbr->decodeExpiry( $row->pr_expiry );
2771
			if ( $expiry > $now ) {
2772
				if ( $getPages ) {
2773
					$page_id = $row->pr_page;
2774
					$page_ns = $row->page_namespace;
2775
					$page_title = $row->page_title;
2776
					$sources[$page_id] = Title::makeTitle( $page_ns, $page_title );
2777
					# Add groups needed for each restriction type if its not already there
2778
					# Make sure this restriction type still exists
2779
2780
					if ( !isset( $pagerestrictions[$row->pr_type] ) ) {
2781
						$pagerestrictions[$row->pr_type] = [];
2782
					}
2783
2784
					if (
2785
						isset( $pagerestrictions[$row->pr_type] )
2786
						&& !in_array( $row->pr_level, $pagerestrictions[$row->pr_type] )
2787
					) {
2788
						$pagerestrictions[$row->pr_type][] = $row->pr_level;
2789
					}
2790
				} else {
2791
					$sources = true;
2792
				}
2793
			}
2794
		}
2795
2796
		if ( $getPages ) {
2797
			$this->mCascadeSources = $sources;
0 ignored issues
show
Documentation Bug introduced by
It seems like $sources can also be of type boolean. However, the property $mCascadeSources is declared as type array. 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...
2798
			$this->mCascadingRestrictions = $pagerestrictions;
2799
		} else {
2800
			$this->mHasCascadingRestrictions = $sources;
0 ignored issues
show
Documentation Bug introduced by
It seems like $sources can also be of type array. However, the property $mHasCascadingRestrictions 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...
2801
		}
2802
2803
		return [ $sources, $pagerestrictions ];
2804
	}
2805
2806
	/**
2807
	 * Accessor for mRestrictionsLoaded
2808
	 *
2809
	 * @return bool Whether or not the page's restrictions have already been
2810
	 * loaded from the database
2811
	 * @since 1.23
2812
	 */
2813
	public function areRestrictionsLoaded() {
2814
		return $this->mRestrictionsLoaded;
2815
	}
2816
2817
	/**
2818
	 * Accessor/initialisation for mRestrictions
2819
	 *
2820
	 * @param string $action Action that permission needs to be checked for
2821
	 * @return array Restriction levels needed to take the action. All levels are
2822
	 *     required. Note that restriction levels are normally user rights, but 'sysop'
2823
	 *     and 'autoconfirmed' are also allowed for backwards compatibility. These should
2824
	 *     be mapped to 'editprotected' and 'editsemiprotected' respectively.
2825
	 */
2826
	public function getRestrictions( $action ) {
2827
		if ( !$this->mRestrictionsLoaded ) {
2828
			$this->loadRestrictions();
2829
		}
2830
		return isset( $this->mRestrictions[$action] )
2831
				? $this->mRestrictions[$action]
2832
				: [];
2833
	}
2834
2835
	/**
2836
	 * Accessor/initialisation for mRestrictions
2837
	 *
2838
	 * @return array Keys are actions, values are arrays as returned by
2839
	 *     Title::getRestrictions()
2840
	 * @since 1.23
2841
	 */
2842
	public function getAllRestrictions() {
2843
		if ( !$this->mRestrictionsLoaded ) {
2844
			$this->loadRestrictions();
2845
		}
2846
		return $this->mRestrictions;
2847
	}
2848
2849
	/**
2850
	 * Get the expiry time for the restriction against a given action
2851
	 *
2852
	 * @param string $action
2853
	 * @return string|bool 14-char timestamp, or 'infinity' if the page is protected forever
2854
	 *     or not protected at all, or false if the action is not recognised.
2855
	 */
2856
	public function getRestrictionExpiry( $action ) {
2857
		if ( !$this->mRestrictionsLoaded ) {
2858
			$this->loadRestrictions();
2859
		}
2860
		return isset( $this->mRestrictionsExpiry[$action] ) ? $this->mRestrictionsExpiry[$action] : false;
2861
	}
2862
2863
	/**
2864
	 * Returns cascading restrictions for the current article
2865
	 *
2866
	 * @return bool
2867
	 */
2868
	function areRestrictionsCascading() {
2869
		if ( !$this->mRestrictionsLoaded ) {
2870
			$this->loadRestrictions();
2871
		}
2872
2873
		return $this->mCascadeRestriction;
2874
	}
2875
2876
	/**
2877
	 * Compiles list of active page restrictions from both page table (pre 1.10)
2878
	 * and page_restrictions table for this existing page.
2879
	 * Public for usage by LiquidThreads.
2880
	 *
2881
	 * @param array $rows Array of db result objects
2882
	 * @param string $oldFashionedRestrictions Comma-separated list of page
2883
	 *   restrictions from page table (pre 1.10)
2884
	 */
2885
	public function loadRestrictionsFromRows( $rows, $oldFashionedRestrictions = null ) {
2886
		$dbr = wfGetDB( DB_REPLICA );
2887
2888
		$restrictionTypes = $this->getRestrictionTypes();
2889
2890
		foreach ( $restrictionTypes as $type ) {
2891
			$this->mRestrictions[$type] = [];
2892
			$this->mRestrictionsExpiry[$type] = 'infinity';
2893
		}
2894
2895
		$this->mCascadeRestriction = false;
2896
2897
		# Backwards-compatibility: also load the restrictions from the page record (old format).
2898
		if ( $oldFashionedRestrictions !== null ) {
2899
			$this->mOldRestrictions = $oldFashionedRestrictions;
2900
		}
2901
2902
		if ( $this->mOldRestrictions === false ) {
2903
			$this->mOldRestrictions = $dbr->selectField( 'page', 'page_restrictions',
2904
				[ 'page_id' => $this->getArticleID() ], __METHOD__ );
2905
		}
2906
2907
		if ( $this->mOldRestrictions != '' ) {
2908
			foreach ( explode( ':', trim( $this->mOldRestrictions ) ) as $restrict ) {
2909
				$temp = explode( '=', trim( $restrict ) );
2910
				if ( count( $temp ) == 1 ) {
2911
					// old old format should be treated as edit/move restriction
2912
					$this->mRestrictions['edit'] = explode( ',', trim( $temp[0] ) );
2913
					$this->mRestrictions['move'] = explode( ',', trim( $temp[0] ) );
2914
				} else {
2915
					$restriction = trim( $temp[1] );
2916
					if ( $restriction != '' ) { // some old entries are empty
2917
						$this->mRestrictions[$temp[0]] = explode( ',', $restriction );
2918
					}
2919
				}
2920
			}
2921
		}
2922
2923
		if ( count( $rows ) ) {
2924
			# Current system - load second to make them override.
2925
			$now = wfTimestampNow();
2926
2927
			# Cycle through all the restrictions.
2928
			foreach ( $rows as $row ) {
2929
2930
				// Don't take care of restrictions types that aren't allowed
2931
				if ( !in_array( $row->pr_type, $restrictionTypes ) ) {
2932
					continue;
2933
				}
2934
2935
				// This code should be refactored, now that it's being used more generally,
2936
				// But I don't really see any harm in leaving it in Block for now -werdna
2937
				$expiry = $dbr->decodeExpiry( $row->pr_expiry );
2938
2939
				// Only apply the restrictions if they haven't expired!
2940
				if ( !$expiry || $expiry > $now ) {
2941
					$this->mRestrictionsExpiry[$row->pr_type] = $expiry;
2942
					$this->mRestrictions[$row->pr_type] = explode( ',', trim( $row->pr_level ) );
2943
2944
					$this->mCascadeRestriction |= $row->pr_cascade;
2945
				}
2946
			}
2947
		}
2948
2949
		$this->mRestrictionsLoaded = true;
2950
	}
2951
2952
	/**
2953
	 * Load restrictions from the page_restrictions table
2954
	 *
2955
	 * @param string $oldFashionedRestrictions Comma-separated list of page
2956
	 *   restrictions from page table (pre 1.10)
2957
	 */
2958
	public function loadRestrictions( $oldFashionedRestrictions = null ) {
2959
		if ( $this->mRestrictionsLoaded ) {
2960
			return;
2961
		}
2962
2963
		$id = $this->getArticleID();
2964
		if ( $id ) {
2965
			$cache = ObjectCache::getMainWANInstance();
2966
			$rows = $cache->getWithSetCallback(
2967
				// Page protections always leave a new null revision
2968
				$cache->makeKey( 'page-restrictions', $id, $this->getLatestRevID() ),
2969
				$cache::TTL_DAY,
2970
				function ( $curValue, &$ttl, array &$setOpts ) {
2971
					$dbr = wfGetDB( DB_REPLICA );
2972
2973
					$setOpts += Database::getCacheSetOptions( $dbr );
2974
2975
					return iterator_to_array(
2976
						$dbr->select(
2977
							'page_restrictions',
2978
							[ 'pr_type', 'pr_expiry', 'pr_level', 'pr_cascade' ],
2979
							[ 'pr_page' => $this->getArticleID() ],
2980
							__METHOD__
2981
						)
2982
					);
2983
				}
2984
			);
2985
2986
			$this->loadRestrictionsFromRows( $rows, $oldFashionedRestrictions );
2987
		} else {
2988
			$title_protection = $this->getTitleProtection();
2989
2990
			if ( $title_protection ) {
2991
				$now = wfTimestampNow();
2992
				$expiry = wfGetDB( DB_REPLICA )->decodeExpiry( $title_protection['expiry'] );
2993
2994
				if ( !$expiry || $expiry > $now ) {
2995
					// Apply the restrictions
2996
					$this->mRestrictionsExpiry['create'] = $expiry;
2997
					$this->mRestrictions['create'] =
2998
						explode( ',', trim( $title_protection['permission'] ) );
2999
				} else { // Get rid of the old restrictions
3000
					$this->mTitleProtection = false;
3001
				}
3002
			} else {
3003
				$this->mRestrictionsExpiry['create'] = 'infinity';
3004
			}
3005
			$this->mRestrictionsLoaded = true;
3006
		}
3007
	}
3008
3009
	/**
3010
	 * Flush the protection cache in this object and force reload from the database.
3011
	 * This is used when updating protection from WikiPage::doUpdateRestrictions().
3012
	 */
3013
	public function flushRestrictions() {
3014
		$this->mRestrictionsLoaded = false;
3015
		$this->mTitleProtection = null;
3016
	}
3017
3018
	/**
3019
	 * Purge expired restrictions from the page_restrictions table
3020
	 *
3021
	 * This will purge no more than $wgUpdateRowsPerQuery page_restrictions rows
3022
	 */
3023
	static function purgeExpiredRestrictions() {
3024
		if ( wfReadOnly() ) {
3025
			return;
3026
		}
3027
3028
		DeferredUpdates::addUpdate( new AtomicSectionUpdate(
3029
			wfGetDB( DB_MASTER ),
3030
			__METHOD__,
3031
			function ( IDatabase $dbw, $fname ) {
3032
				$config = MediaWikiServices::getInstance()->getMainConfig();
3033
				$ids = $dbw->selectFieldValues(
3034
					'page_restrictions',
3035
					'pr_id',
3036
					[ 'pr_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ],
3037
					$fname,
3038
					[ 'LIMIT' => $config->get( 'UpdateRowsPerQuery' ) ] // T135470
3039
				);
3040
				if ( $ids ) {
3041
					$dbw->delete( 'page_restrictions', [ 'pr_id' => $ids ], $fname );
3042
				}
3043
			}
3044
		) );
3045
3046
		DeferredUpdates::addUpdate( new AtomicSectionUpdate(
3047
			wfGetDB( DB_MASTER ),
3048
			__METHOD__,
3049
			function ( IDatabase $dbw, $fname ) {
3050
				$dbw->delete(
3051
					'protected_titles',
3052
					[ 'pt_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ],
3053
					$fname
3054
				);
3055
			}
3056
		) );
3057
	}
3058
3059
	/**
3060
	 * Does this have subpages?  (Warning, usually requires an extra DB query.)
3061
	 *
3062
	 * @return bool
3063
	 */
3064
	public function hasSubpages() {
3065
		if ( !MWNamespace::hasSubpages( $this->mNamespace ) ) {
3066
			# Duh
3067
			return false;
3068
		}
3069
3070
		# We dynamically add a member variable for the purpose of this method
3071
		# alone to cache the result.  There's no point in having it hanging
3072
		# around uninitialized in every Title object; therefore we only add it
3073
		# if needed and don't declare it statically.
3074
		if ( $this->mHasSubpages === null ) {
3075
			$this->mHasSubpages = false;
3076
			$subpages = $this->getSubpages( 1 );
3077
			if ( $subpages instanceof TitleArray ) {
3078
				$this->mHasSubpages = (bool)$subpages->count();
3079
			}
3080
		}
3081
3082
		return $this->mHasSubpages;
3083
	}
3084
3085
	/**
3086
	 * Get all subpages of this page.
3087
	 *
3088
	 * @param int $limit Maximum number of subpages to fetch; -1 for no limit
3089
	 * @return TitleArray|array TitleArray, or empty array if this page's namespace
3090
	 *  doesn't allow subpages
3091
	 */
3092
	public function getSubpages( $limit = -1 ) {
3093
		if ( !MWNamespace::hasSubpages( $this->getNamespace() ) ) {
3094
			return [];
3095
		}
3096
3097
		$dbr = wfGetDB( DB_REPLICA );
3098
		$conds['page_namespace'] = $this->getNamespace();
3099
		$conds[] = 'page_title ' . $dbr->buildLike( $this->getDBkey() . '/', $dbr->anyString() );
3100
		$options = [];
3101
		if ( $limit > -1 ) {
3102
			$options['LIMIT'] = $limit;
3103
		}
3104
		$this->mSubpages = TitleArray::newFromResult(
3105
			$dbr->select( 'page',
3106
				[ 'page_id', 'page_namespace', 'page_title', 'page_is_redirect' ],
3107
				$conds,
3108
				__METHOD__,
3109
				$options
3110
			)
3111
		);
3112
		return $this->mSubpages;
3113
	}
3114
3115
	/**
3116
	 * Is there a version of this page in the deletion archive?
3117
	 *
3118
	 * @return int The number of archived revisions
3119
	 */
3120
	public function isDeleted() {
3121
		if ( $this->getNamespace() < 0 ) {
3122
			$n = 0;
3123
		} else {
3124
			$dbr = wfGetDB( DB_REPLICA );
3125
3126
			$n = $dbr->selectField( 'archive', 'COUNT(*)',
3127
				[ 'ar_namespace' => $this->getNamespace(), 'ar_title' => $this->getDBkey() ],
3128
				__METHOD__
3129
			);
3130 View Code Duplication
			if ( $this->getNamespace() == NS_FILE ) {
3131
				$n += $dbr->selectField( 'filearchive', 'COUNT(*)',
3132
					[ 'fa_name' => $this->getDBkey() ],
3133
					__METHOD__
3134
				);
3135
			}
3136
		}
3137
		return (int)$n;
3138
	}
3139
3140
	/**
3141
	 * Is there a version of this page in the deletion archive?
3142
	 *
3143
	 * @return bool
3144
	 */
3145
	public function isDeletedQuick() {
3146
		if ( $this->getNamespace() < 0 ) {
3147
			return false;
3148
		}
3149
		$dbr = wfGetDB( DB_REPLICA );
3150
		$deleted = (bool)$dbr->selectField( 'archive', '1',
3151
			[ 'ar_namespace' => $this->getNamespace(), 'ar_title' => $this->getDBkey() ],
3152
			__METHOD__
3153
		);
3154 View Code Duplication
		if ( !$deleted && $this->getNamespace() == NS_FILE ) {
3155
			$deleted = (bool)$dbr->selectField( 'filearchive', '1',
3156
				[ 'fa_name' => $this->getDBkey() ],
3157
				__METHOD__
3158
			);
3159
		}
3160
		return $deleted;
3161
	}
3162
3163
	/**
3164
	 * Get the article ID for this Title from the link cache,
3165
	 * adding it if necessary
3166
	 *
3167
	 * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select
3168
	 *  for update
3169
	 * @return int The ID
3170
	 */
3171
	public function getArticleID( $flags = 0 ) {
3172
		if ( $this->getNamespace() < 0 ) {
3173
			$this->mArticleID = 0;
3174
			return $this->mArticleID;
3175
		}
3176
		$linkCache = LinkCache::singleton();
3177
		if ( $flags & self::GAID_FOR_UPDATE ) {
3178
			$oldUpdate = $linkCache->forUpdate( true );
3179
			$linkCache->clearLink( $this );
3180
			$this->mArticleID = $linkCache->addLinkObj( $this );
3181
			$linkCache->forUpdate( $oldUpdate );
3182
		} else {
3183
			if ( -1 == $this->mArticleID ) {
3184
				$this->mArticleID = $linkCache->addLinkObj( $this );
3185
			}
3186
		}
3187
		return $this->mArticleID;
3188
	}
3189
3190
	/**
3191
	 * Is this an article that is a redirect page?
3192
	 * Uses link cache, adding it if necessary
3193
	 *
3194
	 * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select for update
3195
	 * @return bool
3196
	 */
3197
	public function isRedirect( $flags = 0 ) {
3198
		if ( !is_null( $this->mRedirect ) ) {
3199
			return $this->mRedirect;
3200
		}
3201
		if ( !$this->getArticleID( $flags ) ) {
3202
			$this->mRedirect = false;
3203
			return $this->mRedirect;
3204
		}
3205
3206
		$linkCache = LinkCache::singleton();
3207
		$linkCache->addLinkObj( $this ); # in case we already had an article ID
3208
		$cached = $linkCache->getGoodLinkFieldObj( $this, 'redirect' );
3209
		if ( $cached === null ) {
3210
			# Trust LinkCache's state over our own
3211
			# LinkCache is telling us that the page doesn't exist, despite there being cached
3212
			# data relating to an existing page in $this->mArticleID. Updaters should clear
3213
			# LinkCache as appropriate, or use $flags = Title::GAID_FOR_UPDATE. If that flag is
3214
			# set, then LinkCache will definitely be up to date here, since getArticleID() forces
3215
			# LinkCache to refresh its data from the master.
3216
			$this->mRedirect = false;
3217
			return $this->mRedirect;
3218
		}
3219
3220
		$this->mRedirect = (bool)$cached;
3221
3222
		return $this->mRedirect;
3223
	}
3224
3225
	/**
3226
	 * What is the length of this page?
3227
	 * Uses link cache, adding it if necessary
3228
	 *
3229
	 * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select for update
3230
	 * @return int
3231
	 */
3232
	public function getLength( $flags = 0 ) {
3233
		if ( $this->mLength != -1 ) {
3234
			return $this->mLength;
3235
		}
3236
		if ( !$this->getArticleID( $flags ) ) {
3237
			$this->mLength = 0;
3238
			return $this->mLength;
3239
		}
3240
		$linkCache = LinkCache::singleton();
3241
		$linkCache->addLinkObj( $this ); # in case we already had an article ID
3242
		$cached = $linkCache->getGoodLinkFieldObj( $this, 'length' );
3243
		if ( $cached === null ) {
3244
			# Trust LinkCache's state over our own, as for isRedirect()
3245
			$this->mLength = 0;
3246
			return $this->mLength;
3247
		}
3248
3249
		$this->mLength = intval( $cached );
3250
3251
		return $this->mLength;
3252
	}
3253
3254
	/**
3255
	 * What is the page_latest field for this page?
3256
	 *
3257
	 * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select for update
3258
	 * @return int Int or 0 if the page doesn't exist
3259
	 */
3260
	public function getLatestRevID( $flags = 0 ) {
3261
		if ( !( $flags & Title::GAID_FOR_UPDATE ) && $this->mLatestID !== false ) {
3262
			return intval( $this->mLatestID );
3263
		}
3264
		if ( !$this->getArticleID( $flags ) ) {
3265
			$this->mLatestID = 0;
3266
			return $this->mLatestID;
3267
		}
3268
		$linkCache = LinkCache::singleton();
3269
		$linkCache->addLinkObj( $this ); # in case we already had an article ID
3270
		$cached = $linkCache->getGoodLinkFieldObj( $this, 'revision' );
3271
		if ( $cached === null ) {
3272
			# Trust LinkCache's state over our own, as for isRedirect()
3273
			$this->mLatestID = 0;
3274
			return $this->mLatestID;
3275
		}
3276
3277
		$this->mLatestID = intval( $cached );
3278
3279
		return $this->mLatestID;
3280
	}
3281
3282
	/**
3283
	 * This clears some fields in this object, and clears any associated
3284
	 * keys in the "bad links" section of the link cache.
3285
	 *
3286
	 * - This is called from WikiPage::doEditContent() and WikiPage::insertOn() to allow
3287
	 * loading of the new page_id. It's also called from
3288
	 * WikiPage::doDeleteArticleReal()
3289
	 *
3290
	 * @param int $newid The new Article ID
3291
	 */
3292
	public function resetArticleID( $newid ) {
3293
		$linkCache = LinkCache::singleton();
3294
		$linkCache->clearLink( $this );
3295
3296
		if ( $newid === false ) {
3297
			$this->mArticleID = -1;
3298
		} else {
3299
			$this->mArticleID = intval( $newid );
3300
		}
3301
		$this->mRestrictionsLoaded = false;
3302
		$this->mRestrictions = [];
3303
		$this->mOldRestrictions = false;
3304
		$this->mRedirect = null;
3305
		$this->mLength = -1;
3306
		$this->mLatestID = false;
3307
		$this->mContentModel = false;
3308
		$this->mEstimateRevisions = null;
3309
		$this->mPageLanguage = false;
3310
		$this->mDbPageLanguage = false;
3311
		$this->mIsBigDeletion = null;
3312
	}
3313
3314
	public static function clearCaches() {
3315
		$linkCache = LinkCache::singleton();
3316
		$linkCache->clear();
3317
3318
		$titleCache = self::getTitleCache();
3319
		$titleCache->clear();
3320
	}
3321
3322
	/**
3323
	 * Capitalize a text string for a title if it belongs to a namespace that capitalizes
3324
	 *
3325
	 * @param string $text Containing title to capitalize
3326
	 * @param int $ns Namespace index, defaults to NS_MAIN
3327
	 * @return string Containing capitalized title
3328
	 */
3329
	public static function capitalize( $text, $ns = NS_MAIN ) {
3330
		global $wgContLang;
3331
3332
		if ( MWNamespace::isCapitalized( $ns ) ) {
3333
			return $wgContLang->ucfirst( $text );
3334
		} else {
3335
			return $text;
3336
		}
3337
	}
3338
3339
	/**
3340
	 * Secure and split - main initialisation function for this object
3341
	 *
3342
	 * Assumes that mDbkeyform has been set, and is urldecoded
3343
	 * and uses underscores, but not otherwise munged.  This function
3344
	 * removes illegal characters, splits off the interwiki and
3345
	 * namespace prefixes, sets the other forms, and canonicalizes
3346
	 * everything.
3347
	 *
3348
	 * @throws MalformedTitleException On invalid titles
3349
	 * @return bool True on success
3350
	 */
3351
	private function secureAndSplit() {
3352
		# Initialisation
3353
		$this->mInterwiki = '';
3354
		$this->mFragment = '';
3355
		$this->mNamespace = $this->mDefaultNamespace; # Usually NS_MAIN
3356
3357
		$dbkey = $this->mDbkeyform;
3358
3359
		// @note: splitTitleString() is a temporary hack to allow MediaWikiTitleCodec to share
3360
		//        the parsing code with Title, while avoiding massive refactoring.
3361
		// @todo: get rid of secureAndSplit, refactor parsing code.
3362
		// @note: getTitleParser() returns a TitleParser implementation which does not have a
3363
		//        splitTitleString method, but the only implementation (MediaWikiTitleCodec) does
3364
		$titleCodec = MediaWikiServices::getInstance()->getTitleParser();
3365
		// MalformedTitleException can be thrown here
3366
		$parts = $titleCodec->splitTitleString( $dbkey, $this->getDefaultNamespace() );
3367
3368
		# Fill fields
3369
		$this->setFragment( '#' . $parts['fragment'] );
3370
		$this->mInterwiki = $parts['interwiki'];
3371
		$this->mLocalInterwiki = $parts['local_interwiki'];
3372
		$this->mNamespace = $parts['namespace'];
3373
		$this->mUserCaseDBKey = $parts['user_case_dbkey'];
3374
3375
		$this->mDbkeyform = $parts['dbkey'];
3376
		$this->mUrlform = wfUrlencode( $this->mDbkeyform );
3377
		$this->mTextform = strtr( $this->mDbkeyform, '_', ' ' );
3378
3379
		# We already know that some pages won't be in the database!
3380
		if ( $this->isExternal() || $this->mNamespace == NS_SPECIAL ) {
3381
			$this->mArticleID = 0;
3382
		}
3383
3384
		return true;
3385
	}
3386
3387
	/**
3388
	 * Get an array of Title objects linking to this Title
3389
	 * Also stores the IDs in the link cache.
3390
	 *
3391
	 * WARNING: do not use this function on arbitrary user-supplied titles!
3392
	 * On heavily-used templates it will max out the memory.
3393
	 *
3394
	 * @param array $options May be FOR UPDATE
3395
	 * @param string $table Table name
3396
	 * @param string $prefix Fields prefix
3397
	 * @return Title[] Array of Title objects linking here
3398
	 */
3399
	public function getLinksTo( $options = [], $table = 'pagelinks', $prefix = 'pl' ) {
3400
		if ( count( $options ) > 0 ) {
3401
			$db = wfGetDB( DB_MASTER );
3402
		} else {
3403
			$db = wfGetDB( DB_REPLICA );
3404
		}
3405
3406
		$res = $db->select(
3407
			[ 'page', $table ],
3408
			self::getSelectFields(),
3409
			[
3410
				"{$prefix}_from=page_id",
3411
				"{$prefix}_namespace" => $this->getNamespace(),
3412
				"{$prefix}_title" => $this->getDBkey() ],
3413
			__METHOD__,
3414
			$options
3415
		);
3416
3417
		$retVal = [];
3418
		if ( $res->numRows() ) {
3419
			$linkCache = LinkCache::singleton();
3420 View Code Duplication
			foreach ( $res as $row ) {
3421
				$titleObj = Title::makeTitle( $row->page_namespace, $row->page_title );
3422
				if ( $titleObj ) {
3423
					$linkCache->addGoodLinkObjFromRow( $titleObj, $row );
3424
					$retVal[] = $titleObj;
3425
				}
3426
			}
3427
		}
3428
		return $retVal;
3429
	}
3430
3431
	/**
3432
	 * Get an array of Title objects using this Title as a template
3433
	 * Also stores the IDs in the link cache.
3434
	 *
3435
	 * WARNING: do not use this function on arbitrary user-supplied titles!
3436
	 * On heavily-used templates it will max out the memory.
3437
	 *
3438
	 * @param array $options Query option to Database::select()
3439
	 * @return Title[] Array of Title the Title objects linking here
3440
	 */
3441
	public function getTemplateLinksTo( $options = [] ) {
3442
		return $this->getLinksTo( $options, 'templatelinks', 'tl' );
3443
	}
3444
3445
	/**
3446
	 * Get an array of Title objects linked from this Title
3447
	 * Also stores the IDs in the link cache.
3448
	 *
3449
	 * WARNING: do not use this function on arbitrary user-supplied titles!
3450
	 * On heavily-used templates it will max out the memory.
3451
	 *
3452
	 * @param array $options Query option to Database::select()
3453
	 * @param string $table Table name
3454
	 * @param string $prefix Fields prefix
3455
	 * @return array Array of Title objects linking here
3456
	 */
3457
	public function getLinksFrom( $options = [], $table = 'pagelinks', $prefix = 'pl' ) {
3458
		$id = $this->getArticleID();
3459
3460
		# If the page doesn't exist; there can't be any link from this page
3461
		if ( !$id ) {
3462
			return [];
3463
		}
3464
3465
		$db = wfGetDB( DB_REPLICA );
3466
3467
		$blNamespace = "{$prefix}_namespace";
3468
		$blTitle = "{$prefix}_title";
3469
3470
		$res = $db->select(
3471
			[ $table, 'page' ],
3472
			array_merge(
3473
				[ $blNamespace, $blTitle ],
3474
				WikiPage::selectFields()
3475
			),
3476
			[ "{$prefix}_from" => $id ],
3477
			__METHOD__,
3478
			$options,
3479
			[ 'page' => [
3480
				'LEFT JOIN',
3481
				[ "page_namespace=$blNamespace", "page_title=$blTitle" ]
3482
			] ]
3483
		);
3484
3485
		$retVal = [];
3486
		$linkCache = LinkCache::singleton();
3487
		foreach ( $res as $row ) {
3488
			if ( $row->page_id ) {
3489
				$titleObj = Title::newFromRow( $row );
3490
			} else {
3491
				$titleObj = Title::makeTitle( $row->$blNamespace, $row->$blTitle );
3492
				$linkCache->addBadLinkObj( $titleObj );
3493
			}
3494
			$retVal[] = $titleObj;
3495
		}
3496
3497
		return $retVal;
3498
	}
3499
3500
	/**
3501
	 * Get an array of Title objects used on this Title as a template
3502
	 * Also stores the IDs in the link cache.
3503
	 *
3504
	 * WARNING: do not use this function on arbitrary user-supplied titles!
3505
	 * On heavily-used templates it will max out the memory.
3506
	 *
3507
	 * @param array $options May be FOR UPDATE
3508
	 * @return Title[] Array of Title the Title objects used here
3509
	 */
3510
	public function getTemplateLinksFrom( $options = [] ) {
3511
		return $this->getLinksFrom( $options, 'templatelinks', 'tl' );
3512
	}
3513
3514
	/**
3515
	 * Get an array of Title objects referring to non-existent articles linked
3516
	 * from this page.
3517
	 *
3518
	 * @todo check if needed (used only in SpecialBrokenRedirects.php, and
3519
	 *   should use redirect table in this case).
3520
	 * @return Title[] Array of Title the Title objects
3521
	 */
3522
	public function getBrokenLinksFrom() {
3523
		if ( $this->getArticleID() == 0 ) {
3524
			# All links from article ID 0 are false positives
3525
			return [];
3526
		}
3527
3528
		$dbr = wfGetDB( DB_REPLICA );
3529
		$res = $dbr->select(
3530
			[ 'page', 'pagelinks' ],
3531
			[ 'pl_namespace', 'pl_title' ],
3532
			[
3533
				'pl_from' => $this->getArticleID(),
3534
				'page_namespace IS NULL'
3535
			],
3536
			__METHOD__, [],
3537
			[
3538
				'page' => [
3539
					'LEFT JOIN',
3540
					[ 'pl_namespace=page_namespace', 'pl_title=page_title' ]
3541
				]
3542
			]
3543
		);
3544
3545
		$retVal = [];
3546
		foreach ( $res as $row ) {
3547
			$retVal[] = Title::makeTitle( $row->pl_namespace, $row->pl_title );
3548
		}
3549
		return $retVal;
3550
	}
3551
3552
	/**
3553
	 * Get a list of URLs to purge from the CDN cache when this
3554
	 * page changes
3555
	 *
3556
	 * @return string[] Array of String the URLs
3557
	 */
3558
	public function getCdnUrls() {
3559
		$urls = [
3560
			$this->getInternalURL(),
3561
			$this->getInternalURL( 'action=history' )
3562
		];
3563
3564
		$pageLang = $this->getPageLanguage();
3565
		if ( $pageLang->hasVariants() ) {
3566
			$variants = $pageLang->getVariants();
3567
			foreach ( $variants as $vCode ) {
3568
				$urls[] = $this->getInternalURL( $vCode );
3569
			}
3570
		}
3571
3572
		// If we are looking at a css/js user subpage, purge the action=raw.
3573
		if ( $this->isJsSubpage() ) {
3574
			$urls[] = $this->getInternalURL( 'action=raw&ctype=text/javascript' );
3575
		} elseif ( $this->isCssSubpage() ) {
3576
			$urls[] = $this->getInternalURL( 'action=raw&ctype=text/css' );
3577
		}
3578
3579
		Hooks::run( 'TitleSquidURLs', [ $this, &$urls ] );
3580
		return $urls;
3581
	}
3582
3583
	/**
3584
	 * @deprecated since 1.27 use getCdnUrls()
3585
	 */
3586
	public function getSquidURLs() {
3587
		return $this->getCdnUrls();
3588
	}
3589
3590
	/**
3591
	 * Purge all applicable CDN URLs
3592
	 */
3593
	public function purgeSquid() {
3594
		DeferredUpdates::addUpdate(
3595
			new CdnCacheUpdate( $this->getCdnUrls() ),
3596
			DeferredUpdates::PRESEND
3597
		);
3598
	}
3599
3600
	/**
3601
	 * Move this page without authentication
3602
	 *
3603
	 * @deprecated since 1.25 use MovePage class instead
3604
	 * @param Title $nt The new page Title
3605
	 * @return array|bool True on success, getUserPermissionsErrors()-like array on failure
3606
	 */
3607
	public function moveNoAuth( &$nt ) {
3608
		wfDeprecated( __METHOD__, '1.25' );
3609
		return $this->moveTo( $nt, false );
3610
	}
3611
3612
	/**
3613
	 * Check whether a given move operation would be valid.
3614
	 * Returns true if ok, or a getUserPermissionsErrors()-like array otherwise
3615
	 *
3616
	 * @deprecated since 1.25, use MovePage's methods instead
3617
	 * @param Title $nt The new title
3618
	 * @param bool $auth Whether to check user permissions (uses $wgUser)
3619
	 * @param string $reason Is the log summary of the move, used for spam checking
3620
	 * @return array|bool True on success, getUserPermissionsErrors()-like array on failure
3621
	 */
3622
	public function isValidMoveOperation( &$nt, $auth = true, $reason = '' ) {
3623
		global $wgUser;
3624
3625
		if ( !( $nt instanceof Title ) ) {
3626
			// Normally we'd add this to $errors, but we'll get
3627
			// lots of syntax errors if $nt is not an object
3628
			return [ [ 'badtitletext' ] ];
3629
		}
3630
3631
		$mp = new MovePage( $this, $nt );
3632
		$errors = $mp->isValidMove()->getErrorsArray();
3633
		if ( $auth ) {
3634
			$errors = wfMergeErrorArrays(
3635
				$errors,
3636
				$mp->checkPermissions( $wgUser, $reason )->getErrorsArray()
3637
			);
3638
		}
3639
3640
		return $errors ?: true;
3641
	}
3642
3643
	/**
3644
	 * Check if the requested move target is a valid file move target
3645
	 * @todo move this to MovePage
3646
	 * @param Title $nt Target title
3647
	 * @return array List of errors
3648
	 */
3649
	protected function validateFileMoveOperation( $nt ) {
3650
		global $wgUser;
3651
3652
		$errors = [];
3653
3654
		$destFile = wfLocalFile( $nt );
3655
		$destFile->load( File::READ_LATEST );
3656
		if ( !$wgUser->isAllowed( 'reupload-shared' )
3657
			&& !$destFile->exists() && wfFindFile( $nt )
3658
		) {
3659
			$errors[] = [ 'file-exists-sharedrepo' ];
3660
		}
3661
3662
		return $errors;
3663
	}
3664
3665
	/**
3666
	 * Move a title to a new location
3667
	 *
3668
	 * @deprecated since 1.25, use the MovePage class instead
3669
	 * @param Title $nt The new title
3670
	 * @param bool $auth Indicates whether $wgUser's permissions
3671
	 *  should be checked
3672
	 * @param string $reason The reason for the move
3673
	 * @param bool $createRedirect Whether to create a redirect from the old title to the new title.
3674
	 *  Ignored if the user doesn't have the suppressredirect right.
3675
	 * @return array|bool True on success, getUserPermissionsErrors()-like array on failure
3676
	 */
3677
	public function moveTo( &$nt, $auth = true, $reason = '', $createRedirect = true ) {
3678
		global $wgUser;
3679
		$err = $this->isValidMoveOperation( $nt, $auth, $reason );
3680
		if ( is_array( $err ) ) {
3681
			// Auto-block user's IP if the account was "hard" blocked
3682
			$wgUser->spreadAnyEditBlock();
3683
			return $err;
3684
		}
3685
		// Check suppressredirect permission
3686
		if ( $auth && !$wgUser->isAllowed( 'suppressredirect' ) ) {
3687
			$createRedirect = true;
3688
		}
3689
3690
		$mp = new MovePage( $this, $nt );
3691
		$status = $mp->move( $wgUser, $reason, $createRedirect );
3692
		if ( $status->isOK() ) {
3693
			return true;
3694
		} else {
3695
			return $status->getErrorsArray();
3696
		}
3697
	}
3698
3699
	/**
3700
	 * Move this page's subpages to be subpages of $nt
3701
	 *
3702
	 * @param Title $nt Move target
3703
	 * @param bool $auth Whether $wgUser's permissions should be checked
3704
	 * @param string $reason The reason for the move
3705
	 * @param bool $createRedirect Whether to create redirects from the old subpages to
3706
	 *     the new ones Ignored if the user doesn't have the 'suppressredirect' right
3707
	 * @return array Array with old page titles as keys, and strings (new page titles) or
3708
	 *     arrays (errors) as values, or an error array with numeric indices if no pages
3709
	 *     were moved
3710
	 */
3711
	public function moveSubpages( $nt, $auth = true, $reason = '', $createRedirect = true ) {
3712
		global $wgMaximumMovedPages;
3713
		// Check permissions
3714
		if ( !$this->userCan( 'move-subpages' ) ) {
3715
			return [ 'cant-move-subpages' ];
3716
		}
3717
		// Do the source and target namespaces support subpages?
3718
		if ( !MWNamespace::hasSubpages( $this->getNamespace() ) ) {
3719
			return [ 'namespace-nosubpages',
3720
				MWNamespace::getCanonicalName( $this->getNamespace() ) ];
3721
		}
3722
		if ( !MWNamespace::hasSubpages( $nt->getNamespace() ) ) {
3723
			return [ 'namespace-nosubpages',
3724
				MWNamespace::getCanonicalName( $nt->getNamespace() ) ];
3725
		}
3726
3727
		$subpages = $this->getSubpages( $wgMaximumMovedPages + 1 );
3728
		$retval = [];
3729
		$count = 0;
3730
		foreach ( $subpages as $oldSubpage ) {
3731
			$count++;
3732
			if ( $count > $wgMaximumMovedPages ) {
3733
				$retval[$oldSubpage->getPrefixedText()] =
3734
						[ 'movepage-max-pages',
3735
							$wgMaximumMovedPages ];
3736
				break;
3737
			}
3738
3739
			// We don't know whether this function was called before
3740
			// or after moving the root page, so check both
3741
			// $this and $nt
3742
			if ( $oldSubpage->getArticleID() == $this->getArticleID()
3743
				|| $oldSubpage->getArticleID() == $nt->getArticleID()
3744
			) {
3745
				// When moving a page to a subpage of itself,
3746
				// don't move it twice
3747
				continue;
3748
			}
3749
			$newPageName = preg_replace(
3750
					'#^' . preg_quote( $this->getDBkey(), '#' ) . '#',
3751
					StringUtils::escapeRegexReplacement( $nt->getDBkey() ), # bug 21234
3752
					$oldSubpage->getDBkey() );
3753
			if ( $oldSubpage->isTalkPage() ) {
3754
				$newNs = $nt->getTalkPage()->getNamespace();
3755
			} else {
3756
				$newNs = $nt->getSubjectPage()->getNamespace();
3757
			}
3758
			# Bug 14385: we need makeTitleSafe because the new page names may
3759
			# be longer than 255 characters.
3760
			$newSubpage = Title::makeTitleSafe( $newNs, $newPageName );
3761
3762
			$success = $oldSubpage->moveTo( $newSubpage, $auth, $reason, $createRedirect );
3763
			if ( $success === true ) {
3764
				$retval[$oldSubpage->getPrefixedText()] = $newSubpage->getPrefixedText();
3765
			} else {
3766
				$retval[$oldSubpage->getPrefixedText()] = $success;
3767
			}
3768
		}
3769
		return $retval;
3770
	}
3771
3772
	/**
3773
	 * Checks if this page is just a one-rev redirect.
3774
	 * Adds lock, so don't use just for light purposes.
3775
	 *
3776
	 * @return bool
3777
	 */
3778
	public function isSingleRevRedirect() {
3779
		global $wgContentHandlerUseDB;
3780
3781
		$dbw = wfGetDB( DB_MASTER );
3782
3783
		# Is it a redirect?
3784
		$fields = [ 'page_is_redirect', 'page_latest', 'page_id' ];
3785
		if ( $wgContentHandlerUseDB ) {
3786
			$fields[] = 'page_content_model';
3787
		}
3788
3789
		$row = $dbw->selectRow( 'page',
3790
			$fields,
3791
			$this->pageCond(),
3792
			__METHOD__,
3793
			[ 'FOR UPDATE' ]
3794
		);
3795
		# Cache some fields we may want
3796
		$this->mArticleID = $row ? intval( $row->page_id ) : 0;
3797
		$this->mRedirect = $row ? (bool)$row->page_is_redirect : false;
3798
		$this->mLatestID = $row ? intval( $row->page_latest ) : false;
3799
		$this->mContentModel = $row && isset( $row->page_content_model )
3800
			? strval( $row->page_content_model )
3801
			: false;
3802
3803
		if ( !$this->mRedirect ) {
3804
			return false;
3805
		}
3806
		# Does the article have a history?
3807
		$row = $dbw->selectField( [ 'page', 'revision' ],
3808
			'rev_id',
3809
			[ 'page_namespace' => $this->getNamespace(),
3810
				'page_title' => $this->getDBkey(),
3811
				'page_id=rev_page',
3812
				'page_latest != rev_id'
3813
			],
3814
			__METHOD__,
3815
			[ 'FOR UPDATE' ]
3816
		);
3817
		# Return true if there was no history
3818
		return ( $row === false );
3819
	}
3820
3821
	/**
3822
	 * Checks if $this can be moved to a given Title
3823
	 * - Selects for update, so don't call it unless you mean business
3824
	 *
3825
	 * @deprecated since 1.25, use MovePage's methods instead
3826
	 * @param Title $nt The new title to check
3827
	 * @return bool
3828
	 */
3829
	public function isValidMoveTarget( $nt ) {
3830
		# Is it an existing file?
3831
		if ( $nt->getNamespace() == NS_FILE ) {
3832
			$file = wfLocalFile( $nt );
3833
			$file->load( File::READ_LATEST );
3834
			if ( $file->exists() ) {
3835
				wfDebug( __METHOD__ . ": file exists\n" );
3836
				return false;
3837
			}
3838
		}
3839
		# Is it a redirect with no history?
3840
		if ( !$nt->isSingleRevRedirect() ) {
3841
			wfDebug( __METHOD__ . ": not a one-rev redirect\n" );
3842
			return false;
3843
		}
3844
		# Get the article text
3845
		$rev = Revision::newFromTitle( $nt, false, Revision::READ_LATEST );
3846
		if ( !is_object( $rev ) ) {
3847
			return false;
3848
		}
3849
		$content = $rev->getContent();
3850
		# Does the redirect point to the source?
3851
		# Or is it a broken self-redirect, usually caused by namespace collisions?
3852
		$redirTitle = $content ? $content->getRedirectTarget() : null;
3853
3854
		if ( $redirTitle ) {
3855
			if ( $redirTitle->getPrefixedDBkey() != $this->getPrefixedDBkey() &&
3856
				$redirTitle->getPrefixedDBkey() != $nt->getPrefixedDBkey() ) {
3857
				wfDebug( __METHOD__ . ": redirect points to other page\n" );
3858
				return false;
3859
			} else {
3860
				return true;
3861
			}
3862
		} else {
3863
			# Fail safe (not a redirect after all. strange.)
3864
			wfDebug( __METHOD__ . ": failsafe: database sais " . $nt->getPrefixedDBkey() .
3865
						" is a redirect, but it doesn't contain a valid redirect.\n" );
3866
			return false;
3867
		}
3868
	}
3869
3870
	/**
3871
	 * Get categories to which this Title belongs and return an array of
3872
	 * categories' names.
3873
	 *
3874
	 * @return array Array of parents in the form:
3875
	 *	  $parent => $currentarticle
3876
	 */
3877
	public function getParentCategories() {
3878
		global $wgContLang;
3879
3880
		$data = [];
3881
3882
		$titleKey = $this->getArticleID();
3883
3884
		if ( $titleKey === 0 ) {
3885
			return $data;
3886
		}
3887
3888
		$dbr = wfGetDB( DB_REPLICA );
3889
3890
		$res = $dbr->select(
3891
			'categorylinks',
3892
			'cl_to',
3893
			[ 'cl_from' => $titleKey ],
3894
			__METHOD__
3895
		);
3896
3897
		if ( $res->numRows() > 0 ) {
3898
			foreach ( $res as $row ) {
3899
				// $data[] = Title::newFromText($wgContLang->getNsText ( NS_CATEGORY ).':'.$row->cl_to);
3900
				$data[$wgContLang->getNsText( NS_CATEGORY ) . ':' . $row->cl_to] = $this->getFullText();
3901
			}
3902
		}
3903
		return $data;
3904
	}
3905
3906
	/**
3907
	 * Get a tree of parent categories
3908
	 *
3909
	 * @param array $children Array with the children in the keys, to check for circular refs
3910
	 * @return array Tree of parent categories
3911
	 */
3912
	public function getParentCategoryTree( $children = [] ) {
3913
		$stack = [];
3914
		$parents = $this->getParentCategories();
3915
3916
		if ( $parents ) {
3917
			foreach ( $parents as $parent => $current ) {
3918
				if ( array_key_exists( $parent, $children ) ) {
3919
					# Circular reference
3920
					$stack[$parent] = [];
3921
				} else {
3922
					$nt = Title::newFromText( $parent );
3923
					if ( $nt ) {
3924
						$stack[$parent] = $nt->getParentCategoryTree( $children + [ $parent => 1 ] );
3925
					}
3926
				}
3927
			}
3928
		}
3929
3930
		return $stack;
3931
	}
3932
3933
	/**
3934
	 * Get an associative array for selecting this title from
3935
	 * the "page" table
3936
	 *
3937
	 * @return array Array suitable for the $where parameter of DB::select()
3938
	 */
3939
	public function pageCond() {
3940
		if ( $this->mArticleID > 0 ) {
3941
			// PK avoids secondary lookups in InnoDB, shouldn't hurt other DBs
3942
			return [ 'page_id' => $this->mArticleID ];
3943
		} else {
3944
			return [ 'page_namespace' => $this->mNamespace, 'page_title' => $this->mDbkeyform ];
3945
		}
3946
	}
3947
3948
	/**
3949
	 * Get the revision ID of the previous revision
3950
	 *
3951
	 * @param int $revId Revision ID. Get the revision that was before this one.
3952
	 * @param int $flags Title::GAID_FOR_UPDATE
3953
	 * @return int|bool Old revision ID, or false if none exists
3954
	 */
3955 View Code Duplication
	public function getPreviousRevisionID( $revId, $flags = 0 ) {
3956
		$db = ( $flags & self::GAID_FOR_UPDATE ) ? wfGetDB( DB_MASTER ) : wfGetDB( DB_REPLICA );
3957
		$revId = $db->selectField( 'revision', 'rev_id',
3958
			[
3959
				'rev_page' => $this->getArticleID( $flags ),
3960
				'rev_id < ' . intval( $revId )
3961
			],
3962
			__METHOD__,
3963
			[ 'ORDER BY' => 'rev_id DESC' ]
3964
		);
3965
3966
		if ( $revId === false ) {
3967
			return false;
3968
		} else {
3969
			return intval( $revId );
3970
		}
3971
	}
3972
3973
	/**
3974
	 * Get the revision ID of the next revision
3975
	 *
3976
	 * @param int $revId Revision ID. Get the revision that was after this one.
3977
	 * @param int $flags Title::GAID_FOR_UPDATE
3978
	 * @return int|bool Next revision ID, or false if none exists
3979
	 */
3980 View Code Duplication
	public function getNextRevisionID( $revId, $flags = 0 ) {
3981
		$db = ( $flags & self::GAID_FOR_UPDATE ) ? wfGetDB( DB_MASTER ) : wfGetDB( DB_REPLICA );
3982
		$revId = $db->selectField( 'revision', 'rev_id',
3983
			[
3984
				'rev_page' => $this->getArticleID( $flags ),
3985
				'rev_id > ' . intval( $revId )
3986
			],
3987
			__METHOD__,
3988
			[ 'ORDER BY' => 'rev_id' ]
3989
		);
3990
3991
		if ( $revId === false ) {
3992
			return false;
3993
		} else {
3994
			return intval( $revId );
3995
		}
3996
	}
3997
3998
	/**
3999
	 * Get the first revision of the page
4000
	 *
4001
	 * @param int $flags Title::GAID_FOR_UPDATE
4002
	 * @return Revision|null If page doesn't exist
4003
	 */
4004
	public function getFirstRevision( $flags = 0 ) {
4005
		$pageId = $this->getArticleID( $flags );
4006
		if ( $pageId ) {
4007
			$db = ( $flags & self::GAID_FOR_UPDATE ) ? wfGetDB( DB_MASTER ) : wfGetDB( DB_REPLICA );
4008
			$row = $db->selectRow( 'revision', Revision::selectFields(),
4009
				[ 'rev_page' => $pageId ],
4010
				__METHOD__,
4011
				[ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 1 ]
4012
			);
4013
			if ( $row ) {
4014
				return new Revision( $row );
4015
			}
4016
		}
4017
		return null;
4018
	}
4019
4020
	/**
4021
	 * Get the oldest revision timestamp of this page
4022
	 *
4023
	 * @param int $flags Title::GAID_FOR_UPDATE
4024
	 * @return string MW timestamp
4025
	 */
4026
	public function getEarliestRevTime( $flags = 0 ) {
4027
		$rev = $this->getFirstRevision( $flags );
4028
		return $rev ? $rev->getTimestamp() : null;
4029
	}
4030
4031
	/**
4032
	 * Check if this is a new page
4033
	 *
4034
	 * @return bool
4035
	 */
4036
	public function isNewPage() {
4037
		$dbr = wfGetDB( DB_REPLICA );
4038
		return (bool)$dbr->selectField( 'page', 'page_is_new', $this->pageCond(), __METHOD__ );
4039
	}
4040
4041
	/**
4042
	 * Check whether the number of revisions of this page surpasses $wgDeleteRevisionsLimit
4043
	 *
4044
	 * @return bool
4045
	 */
4046
	public function isBigDeletion() {
4047
		global $wgDeleteRevisionsLimit;
4048
4049
		if ( !$wgDeleteRevisionsLimit ) {
4050
			return false;
4051
		}
4052
4053
		if ( $this->mIsBigDeletion === null ) {
4054
			$dbr = wfGetDB( DB_REPLICA );
4055
4056
			$revCount = $dbr->selectRowCount(
4057
				'revision',
4058
				'1',
4059
				[ 'rev_page' => $this->getArticleID() ],
4060
				__METHOD__,
4061
				[ 'LIMIT' => $wgDeleteRevisionsLimit + 1 ]
4062
			);
4063
4064
			$this->mIsBigDeletion = $revCount > $wgDeleteRevisionsLimit;
4065
		}
4066
4067
		return $this->mIsBigDeletion;
4068
	}
4069
4070
	/**
4071
	 * Get the approximate revision count of this page.
4072
	 *
4073
	 * @return int
4074
	 */
4075
	public function estimateRevisionCount() {
4076
		if ( !$this->exists() ) {
4077
			return 0;
4078
		}
4079
4080
		if ( $this->mEstimateRevisions === null ) {
4081
			$dbr = wfGetDB( DB_REPLICA );
4082
			$this->mEstimateRevisions = $dbr->estimateRowCount( 'revision', '*',
4083
				[ 'rev_page' => $this->getArticleID() ], __METHOD__ );
4084
		}
4085
4086
		return $this->mEstimateRevisions;
4087
	}
4088
4089
	/**
4090
	 * Get the number of revisions between the given revision.
4091
	 * Used for diffs and other things that really need it.
4092
	 *
4093
	 * @param int|Revision $old Old revision or rev ID (first before range)
4094
	 * @param int|Revision $new New revision or rev ID (first after range)
4095
	 * @param int|null $max Limit of Revisions to count, will be incremented to detect truncations
4096
	 * @return int Number of revisions between these revisions.
4097
	 */
4098
	public function countRevisionsBetween( $old, $new, $max = null ) {
4099
		if ( !( $old instanceof Revision ) ) {
4100
			$old = Revision::newFromTitle( $this, (int)$old );
4101
		}
4102
		if ( !( $new instanceof Revision ) ) {
4103
			$new = Revision::newFromTitle( $this, (int)$new );
4104
		}
4105
		if ( !$old || !$new ) {
4106
			return 0; // nothing to compare
4107
		}
4108
		$dbr = wfGetDB( DB_REPLICA );
4109
		$conds = [
4110
			'rev_page' => $this->getArticleID(),
4111
			'rev_timestamp > ' . $dbr->addQuotes( $dbr->timestamp( $old->getTimestamp() ) ),
4112
			'rev_timestamp < ' . $dbr->addQuotes( $dbr->timestamp( $new->getTimestamp() ) )
4113
		];
4114
		if ( $max !== null ) {
4115
			return $dbr->selectRowCount( 'revision', '1',
4116
				$conds,
4117
				__METHOD__,
4118
				[ 'LIMIT' => $max + 1 ] // extra to detect truncation
4119
			);
4120
		} else {
4121
			return (int)$dbr->selectField( 'revision', 'count(*)', $conds, __METHOD__ );
4122
		}
4123
	}
4124
4125
	/**
4126
	 * Get the authors between the given revisions or revision IDs.
4127
	 * Used for diffs and other things that really need it.
4128
	 *
4129
	 * @since 1.23
4130
	 *
4131
	 * @param int|Revision $old Old revision or rev ID (first before range by default)
4132
	 * @param int|Revision $new New revision or rev ID (first after range by default)
4133
	 * @param int $limit Maximum number of authors
4134
	 * @param string|array $options (Optional): Single option, or an array of options:
4135
	 *     'include_old' Include $old in the range; $new is excluded.
4136
	 *     'include_new' Include $new in the range; $old is excluded.
4137
	 *     'include_both' Include both $old and $new in the range.
4138
	 *     Unknown option values are ignored.
4139
	 * @return array|null Names of revision authors in the range; null if not both revisions exist
4140
	 */
4141
	public function getAuthorsBetween( $old, $new, $limit, $options = [] ) {
4142
		if ( !( $old instanceof Revision ) ) {
4143
			$old = Revision::newFromTitle( $this, (int)$old );
4144
		}
4145
		if ( !( $new instanceof Revision ) ) {
4146
			$new = Revision::newFromTitle( $this, (int)$new );
4147
		}
4148
		// XXX: what if Revision objects are passed in, but they don't refer to this title?
4149
		// Add $old->getPage() != $new->getPage() || $old->getPage() != $this->getArticleID()
4150
		// in the sanity check below?
4151
		if ( !$old || !$new ) {
4152
			return null; // nothing to compare
4153
		}
4154
		$authors = [];
4155
		$old_cmp = '>';
4156
		$new_cmp = '<';
4157
		$options = (array)$options;
4158
		if ( in_array( 'include_old', $options ) ) {
4159
			$old_cmp = '>=';
4160
		}
4161
		if ( in_array( 'include_new', $options ) ) {
4162
			$new_cmp = '<=';
4163
		}
4164
		if ( in_array( 'include_both', $options ) ) {
4165
			$old_cmp = '>=';
4166
			$new_cmp = '<=';
4167
		}
4168
		// No DB query needed if $old and $new are the same or successive revisions:
4169
		if ( $old->getId() === $new->getId() ) {
4170
			return ( $old_cmp === '>' && $new_cmp === '<' ) ?
4171
				[] :
4172
				[ $old->getUserText( Revision::RAW ) ];
4173
		} elseif ( $old->getId() === $new->getParentId() ) {
4174
			if ( $old_cmp === '>=' && $new_cmp === '<=' ) {
4175
				$authors[] = $old->getUserText( Revision::RAW );
4176
				if ( $old->getUserText( Revision::RAW ) != $new->getUserText( Revision::RAW ) ) {
4177
					$authors[] = $new->getUserText( Revision::RAW );
4178
				}
4179
			} elseif ( $old_cmp === '>=' ) {
4180
				$authors[] = $old->getUserText( Revision::RAW );
4181
			} elseif ( $new_cmp === '<=' ) {
4182
				$authors[] = $new->getUserText( Revision::RAW );
4183
			}
4184
			return $authors;
4185
		}
4186
		$dbr = wfGetDB( DB_REPLICA );
4187
		$res = $dbr->select( 'revision', 'DISTINCT rev_user_text',
4188
			[
4189
				'rev_page' => $this->getArticleID(),
4190
				"rev_timestamp $old_cmp " . $dbr->addQuotes( $dbr->timestamp( $old->getTimestamp() ) ),
4191
				"rev_timestamp $new_cmp " . $dbr->addQuotes( $dbr->timestamp( $new->getTimestamp() ) )
4192
			], __METHOD__,
4193
			[ 'LIMIT' => $limit + 1 ] // add one so caller knows it was truncated
4194
		);
4195
		foreach ( $res as $row ) {
4196
			$authors[] = $row->rev_user_text;
4197
		}
4198
		return $authors;
4199
	}
4200
4201
	/**
4202
	 * Get the number of authors between the given revisions or revision IDs.
4203
	 * Used for diffs and other things that really need it.
4204
	 *
4205
	 * @param int|Revision $old Old revision or rev ID (first before range by default)
4206
	 * @param int|Revision $new New revision or rev ID (first after range by default)
4207
	 * @param int $limit Maximum number of authors
4208
	 * @param string|array $options (Optional): Single option, or an array of options:
4209
	 *     'include_old' Include $old in the range; $new is excluded.
4210
	 *     'include_new' Include $new in the range; $old is excluded.
4211
	 *     'include_both' Include both $old and $new in the range.
4212
	 *     Unknown option values are ignored.
4213
	 * @return int Number of revision authors in the range; zero if not both revisions exist
4214
	 */
4215
	public function countAuthorsBetween( $old, $new, $limit, $options = [] ) {
4216
		$authors = $this->getAuthorsBetween( $old, $new, $limit, $options );
4217
		return $authors ? count( $authors ) : 0;
4218
	}
4219
4220
	/**
4221
	 * Compare with another title.
4222
	 *
4223
	 * @param Title $title
4224
	 * @return bool
4225
	 */
4226
	public function equals( Title $title ) {
4227
		// Note: === is necessary for proper matching of number-like titles.
4228
		return $this->getInterwiki() === $title->getInterwiki()
4229
			&& $this->getNamespace() == $title->getNamespace()
4230
			&& $this->getDBkey() === $title->getDBkey();
4231
	}
4232
4233
	/**
4234
	 * Check if this title is a subpage of another title
4235
	 *
4236
	 * @param Title $title
4237
	 * @return bool
4238
	 */
4239
	public function isSubpageOf( Title $title ) {
4240
		return $this->getInterwiki() === $title->getInterwiki()
4241
			&& $this->getNamespace() == $title->getNamespace()
4242
			&& strpos( $this->getDBkey(), $title->getDBkey() . '/' ) === 0;
4243
	}
4244
4245
	/**
4246
	 * Check if page exists.  For historical reasons, this function simply
4247
	 * checks for the existence of the title in the page table, and will
4248
	 * thus return false for interwiki links, special pages and the like.
4249
	 * If you want to know if a title can be meaningfully viewed, you should
4250
	 * probably call the isKnown() method instead.
4251
	 *
4252
	 * @param int $flags An optional bit field; may be Title::GAID_FOR_UPDATE to check
4253
	 *   from master/for update
4254
	 * @return bool
4255
	 */
4256
	public function exists( $flags = 0 ) {
4257
		$exists = $this->getArticleID( $flags ) != 0;
4258
		Hooks::run( 'TitleExists', [ $this, &$exists ] );
4259
		return $exists;
4260
	}
4261
4262
	/**
4263
	 * Should links to this title be shown as potentially viewable (i.e. as
4264
	 * "bluelinks"), even if there's no record by this title in the page
4265
	 * table?
4266
	 *
4267
	 * This function is semi-deprecated for public use, as well as somewhat
4268
	 * misleadingly named.  You probably just want to call isKnown(), which
4269
	 * calls this function internally.
4270
	 *
4271
	 * (ISSUE: Most of these checks are cheap, but the file existence check
4272
	 * can potentially be quite expensive.  Including it here fixes a lot of
4273
	 * existing code, but we might want to add an optional parameter to skip
4274
	 * it and any other expensive checks.)
4275
	 *
4276
	 * @return bool
4277
	 */
4278
	public function isAlwaysKnown() {
4279
		$isKnown = null;
4280
4281
		/**
4282
		 * Allows overriding default behavior for determining if a page exists.
4283
		 * If $isKnown is kept as null, regular checks happen. If it's
4284
		 * a boolean, this value is returned by the isKnown method.
4285
		 *
4286
		 * @since 1.20
4287
		 *
4288
		 * @param Title $title
4289
		 * @param bool|null $isKnown
4290
		 */
4291
		Hooks::run( 'TitleIsAlwaysKnown', [ $this, &$isKnown ] );
4292
4293
		if ( !is_null( $isKnown ) ) {
4294
			return $isKnown;
4295
		}
4296
4297
		if ( $this->isExternal() ) {
4298
			return true;  // any interwiki link might be viewable, for all we know
4299
		}
4300
4301
		switch ( $this->mNamespace ) {
4302
			case NS_MEDIA:
4303
			case NS_FILE:
4304
				// file exists, possibly in a foreign repo
4305
				return (bool)wfFindFile( $this );
4306
			case NS_SPECIAL:
4307
				// valid special page
4308
				return SpecialPageFactory::exists( $this->getDBkey() );
4309
			case NS_MAIN:
4310
				// selflink, possibly with fragment
4311
				return $this->mDbkeyform == '';
4312
			case NS_MEDIAWIKI:
4313
				// known system message
4314
				return $this->hasSourceText() !== false;
4315
			default:
4316
				return false;
4317
		}
4318
	}
4319
4320
	/**
4321
	 * Does this title refer to a page that can (or might) be meaningfully
4322
	 * viewed?  In particular, this function may be used to determine if
4323
	 * links to the title should be rendered as "bluelinks" (as opposed to
4324
	 * "redlinks" to non-existent pages).
4325
	 * Adding something else to this function will cause inconsistency
4326
	 * since LinkHolderArray calls isAlwaysKnown() and does its own
4327
	 * page existence check.
4328
	 *
4329
	 * @return bool
4330
	 */
4331
	public function isKnown() {
4332
		return $this->isAlwaysKnown() || $this->exists();
4333
	}
4334
4335
	/**
4336
	 * Does this page have source text?
4337
	 *
4338
	 * @return bool
4339
	 */
4340
	public function hasSourceText() {
4341
		if ( $this->exists() ) {
4342
			return true;
4343
		}
4344
4345
		if ( $this->mNamespace == NS_MEDIAWIKI ) {
4346
			// If the page doesn't exist but is a known system message, default
4347
			// message content will be displayed, same for language subpages-
4348
			// Use always content language to avoid loading hundreds of languages
4349
			// to get the link color.
4350
			global $wgContLang;
4351
			list( $name, ) = MessageCache::singleton()->figureMessage(
4352
				$wgContLang->lcfirst( $this->getText() )
4353
			);
4354
			$message = wfMessage( $name )->inLanguage( $wgContLang )->useDatabase( false );
4355
			return $message->exists();
4356
		}
4357
4358
		return false;
4359
	}
4360
4361
	/**
4362
	 * Get the default message text or false if the message doesn't exist
4363
	 *
4364
	 * @return string|bool
4365
	 */
4366
	public function getDefaultMessageText() {
4367
		global $wgContLang;
4368
4369
		if ( $this->getNamespace() != NS_MEDIAWIKI ) { // Just in case
4370
			return false;
4371
		}
4372
4373
		list( $name, $lang ) = MessageCache::singleton()->figureMessage(
4374
			$wgContLang->lcfirst( $this->getText() )
4375
		);
4376
		$message = wfMessage( $name )->inLanguage( $lang )->useDatabase( false );
4377
4378
		if ( $message->exists() ) {
4379
			return $message->plain();
4380
		} else {
4381
			return false;
4382
		}
4383
	}
4384
4385
	/**
4386
	 * Updates page_touched for this page; called from LinksUpdate.php
4387
	 *
4388
	 * @param string $purgeTime [optional] TS_MW timestamp
4389
	 * @return bool True if the update succeeded
4390
	 */
4391
	public function invalidateCache( $purgeTime = null ) {
4392
		if ( wfReadOnly() ) {
4393
			return false;
4394
		} elseif ( $this->mArticleID === 0 ) {
4395
			return true; // avoid gap locking if we know it's not there
4396
		}
4397
4398
		$dbw = wfGetDB( DB_MASTER );
4399
		$dbw->onTransactionPreCommitOrIdle( function () {
4400
			ResourceLoaderWikiModule::invalidateModuleCache( $this, null, null, wfWikiID() );
4401
		} );
4402
4403
		$conds = $this->pageCond();
4404
		DeferredUpdates::addUpdate(
4405
			new AutoCommitUpdate(
4406
				$dbw,
4407
				__METHOD__,
4408
				function ( IDatabase $dbw, $fname ) use ( $conds, $purgeTime ) {
4409
					$dbTimestamp = $dbw->timestamp( $purgeTime ?: time() );
4410
					$dbw->update(
4411
						'page',
4412
						[ 'page_touched' => $dbTimestamp ],
4413
						$conds + [ 'page_touched < ' . $dbw->addQuotes( $dbTimestamp ) ],
4414
						$fname
4415
					);
4416
					MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $this );
4417
				}
4418
			),
4419
			DeferredUpdates::PRESEND
4420
		);
4421
4422
		return true;
4423
	}
4424
4425
	/**
4426
	 * Update page_touched timestamps and send CDN purge messages for
4427
	 * pages linking to this title. May be sent to the job queue depending
4428
	 * on the number of links. Typically called on create and delete.
4429
	 */
4430
	public function touchLinks() {
4431
		DeferredUpdates::addUpdate( new HTMLCacheUpdate( $this, 'pagelinks' ) );
4432
		if ( $this->getNamespace() == NS_CATEGORY ) {
4433
			DeferredUpdates::addUpdate( new HTMLCacheUpdate( $this, 'categorylinks' ) );
4434
		}
4435
	}
4436
4437
	/**
4438
	 * Get the last touched timestamp
4439
	 *
4440
	 * @param IDatabase $db Optional db
4441
	 * @return string Last-touched timestamp
4442
	 */
4443
	public function getTouched( $db = null ) {
4444
		if ( $db === null ) {
4445
			$db = wfGetDB( DB_REPLICA );
4446
		}
4447
		$touched = $db->selectField( 'page', 'page_touched', $this->pageCond(), __METHOD__ );
4448
		return $touched;
4449
	}
4450
4451
	/**
4452
	 * Get the timestamp when this page was updated since the user last saw it.
4453
	 *
4454
	 * @param User $user
4455
	 * @return string|null
4456
	 */
4457
	public function getNotificationTimestamp( $user = null ) {
4458
		global $wgUser;
4459
4460
		// Assume current user if none given
4461
		if ( !$user ) {
4462
			$user = $wgUser;
4463
		}
4464
		// Check cache first
4465
		$uid = $user->getId();
4466
		if ( !$uid ) {
4467
			return false;
4468
		}
4469
		// avoid isset here, as it'll return false for null entries
4470
		if ( array_key_exists( $uid, $this->mNotificationTimestamp ) ) {
4471
			return $this->mNotificationTimestamp[$uid];
4472
		}
4473
		// Don't cache too much!
4474
		if ( count( $this->mNotificationTimestamp ) >= self::CACHE_MAX ) {
4475
			$this->mNotificationTimestamp = [];
4476
		}
4477
4478
		$store = MediaWikiServices::getInstance()->getWatchedItemStore();
4479
		$watchedItem = $store->getWatchedItem( $user, $this );
4480
		if ( $watchedItem ) {
4481
			$this->mNotificationTimestamp[$uid] = $watchedItem->getNotificationTimestamp();
4482
		} else {
4483
			$this->mNotificationTimestamp[$uid] = false;
4484
		}
4485
4486
		return $this->mNotificationTimestamp[$uid];
4487
	}
4488
4489
	/**
4490
	 * Generate strings used for xml 'id' names in monobook tabs
4491
	 *
4492
	 * @param string $prepend Defaults to 'nstab-'
4493
	 * @return string XML 'id' name
4494
	 */
4495
	public function getNamespaceKey( $prepend = 'nstab-' ) {
4496
		global $wgContLang;
4497
		// Gets the subject namespace if this title
4498
		$namespace = MWNamespace::getSubject( $this->getNamespace() );
4499
		// Checks if canonical namespace name exists for namespace
4500
		if ( MWNamespace::exists( $this->getNamespace() ) ) {
4501
			// Uses canonical namespace name
4502
			$namespaceKey = MWNamespace::getCanonicalName( $namespace );
4503
		} else {
4504
			// Uses text of namespace
4505
			$namespaceKey = $this->getSubjectNsText();
4506
		}
4507
		// Makes namespace key lowercase
4508
		$namespaceKey = $wgContLang->lc( $namespaceKey );
4509
		// Uses main
4510
		if ( $namespaceKey == '' ) {
4511
			$namespaceKey = 'main';
4512
		}
4513
		// Changes file to image for backwards compatibility
4514
		if ( $namespaceKey == 'file' ) {
4515
			$namespaceKey = 'image';
4516
		}
4517
		return $prepend . $namespaceKey;
4518
	}
4519
4520
	/**
4521
	 * Get all extant redirects to this Title
4522
	 *
4523
	 * @param int|null $ns Single namespace to consider; null to consider all namespaces
4524
	 * @return Title[] Array of Title redirects to this title
4525
	 */
4526
	public function getRedirectsHere( $ns = null ) {
4527
		$redirs = [];
4528
4529
		$dbr = wfGetDB( DB_REPLICA );
4530
		$where = [
4531
			'rd_namespace' => $this->getNamespace(),
4532
			'rd_title' => $this->getDBkey(),
4533
			'rd_from = page_id'
4534
		];
4535
		if ( $this->isExternal() ) {
4536
			$where['rd_interwiki'] = $this->getInterwiki();
4537
		} else {
4538
			$where[] = 'rd_interwiki = ' . $dbr->addQuotes( '' ) . ' OR rd_interwiki IS NULL';
4539
		}
4540
		if ( !is_null( $ns ) ) {
4541
			$where['page_namespace'] = $ns;
4542
		}
4543
4544
		$res = $dbr->select(
4545
			[ 'redirect', 'page' ],
4546
			[ 'page_namespace', 'page_title' ],
4547
			$where,
4548
			__METHOD__
4549
		);
4550
4551
		foreach ( $res as $row ) {
4552
			$redirs[] = self::newFromRow( $row );
4553
		}
4554
		return $redirs;
4555
	}
4556
4557
	/**
4558
	 * Check if this Title is a valid redirect target
4559
	 *
4560
	 * @return bool
4561
	 */
4562
	public function isValidRedirectTarget() {
4563
		global $wgInvalidRedirectTargets;
4564
4565
		if ( $this->isSpecialPage() ) {
4566
			// invalid redirect targets are stored in a global array, but explicitly disallow Userlogout here
4567
			if ( $this->isSpecial( 'Userlogout' ) ) {
4568
				return false;
4569
			}
4570
4571
			foreach ( $wgInvalidRedirectTargets as $target ) {
4572
				if ( $this->isSpecial( $target ) ) {
4573
					return false;
4574
				}
4575
			}
4576
		}
4577
4578
		return true;
4579
	}
4580
4581
	/**
4582
	 * Get a backlink cache object
4583
	 *
4584
	 * @return BacklinkCache
4585
	 */
4586
	public function getBacklinkCache() {
4587
		return BacklinkCache::get( $this );
4588
	}
4589
4590
	/**
4591
	 * Whether the magic words __INDEX__ and __NOINDEX__ function for  this page.
4592
	 *
4593
	 * @return bool
4594
	 */
4595
	public function canUseNoindex() {
4596
		global $wgExemptFromUserRobotsControl;
4597
4598
		$bannedNamespaces = is_null( $wgExemptFromUserRobotsControl )
4599
			? MWNamespace::getContentNamespaces()
4600
			: $wgExemptFromUserRobotsControl;
4601
4602
		return !in_array( $this->mNamespace, $bannedNamespaces );
4603
4604
	}
4605
4606
	/**
4607
	 * Returns the raw sort key to be used for categories, with the specified
4608
	 * prefix.  This will be fed to Collation::getSortKey() to get a
4609
	 * binary sortkey that can be used for actual sorting.
4610
	 *
4611
	 * @param string $prefix The prefix to be used, specified using
4612
	 *   {{defaultsort:}} or like [[Category:Foo|prefix]].  Empty for no
4613
	 *   prefix.
4614
	 * @return string
4615
	 */
4616
	public function getCategorySortkey( $prefix = '' ) {
4617
		$unprefixed = $this->getText();
4618
4619
		// Anything that uses this hook should only depend
4620
		// on the Title object passed in, and should probably
4621
		// tell the users to run updateCollations.php --force
4622
		// in order to re-sort existing category relations.
4623
		Hooks::run( 'GetDefaultSortkey', [ $this, &$unprefixed ] );
4624
		if ( $prefix !== '' ) {
4625
			# Separate with a line feed, so the unprefixed part is only used as
4626
			# a tiebreaker when two pages have the exact same prefix.
4627
			# In UCA, tab is the only character that can sort above LF
4628
			# so we strip both of them from the original prefix.
4629
			$prefix = strtr( $prefix, "\n\t", '  ' );
4630
			return "$prefix\n$unprefixed";
4631
		}
4632
		return $unprefixed;
4633
	}
4634
4635
	/**
4636
	 * Returns the page language code saved in the database, if $wgPageLanguageUseDB is set
4637
	 * to true in LocalSettings.php, otherwise returns false. If there is no language saved in
4638
	 * the db, it will return NULL.
4639
	 *
4640
	 * @return string|null|bool
4641
	 */
4642
	private function getDbPageLanguageCode() {
4643
		global $wgPageLanguageUseDB;
4644
4645
		// check, if the page language could be saved in the database, and if so and
4646
		// the value is not requested already, lookup the page language using LinkCache
4647
		if ( $wgPageLanguageUseDB && $this->mDbPageLanguage === false ) {
4648
			$linkCache = LinkCache::singleton();
4649
			$linkCache->addLinkObj( $this );
4650
			$this->mDbPageLanguage = $linkCache->getGoodLinkFieldObj( $this, 'lang' );
4651
		}
4652
4653
		return $this->mDbPageLanguage;
4654
	}
4655
4656
	/**
4657
	 * Get the language in which the content of this page is written in
4658
	 * wikitext. Defaults to $wgContLang, but in certain cases it can be
4659
	 * e.g. $wgLang (such as special pages, which are in the user language).
4660
	 *
4661
	 * @since 1.18
4662
	 * @return Language
4663
	 */
4664
	public function getPageLanguage() {
4665
		global $wgLang, $wgLanguageCode;
4666
		if ( $this->isSpecialPage() ) {
4667
			// special pages are in the user language
4668
			return $wgLang;
4669
		}
4670
4671
		// Checking if DB language is set
4672
		$dbPageLanguage = $this->getDbPageLanguageCode();
4673
		if ( $dbPageLanguage ) {
4674
			return wfGetLangObj( $dbPageLanguage );
4675
		}
4676
4677
		if ( !$this->mPageLanguage || $this->mPageLanguage[1] !== $wgLanguageCode ) {
4678
			// Note that this may depend on user settings, so the cache should
4679
			// be only per-request.
4680
			// NOTE: ContentHandler::getPageLanguage() may need to load the
4681
			// content to determine the page language!
4682
			// Checking $wgLanguageCode hasn't changed for the benefit of unit
4683
			// tests.
4684
			$contentHandler = ContentHandler::getForTitle( $this );
4685
			$langObj = $contentHandler->getPageLanguage( $this );
4686
			$this->mPageLanguage = [ $langObj->getCode(), $wgLanguageCode ];
4687
		} else {
4688
			$langObj = wfGetLangObj( $this->mPageLanguage[0] );
4689
		}
4690
4691
		return $langObj;
4692
	}
4693
4694
	/**
4695
	 * Get the language in which the content of this page is written when
4696
	 * viewed by user. Defaults to $wgContLang, but in certain cases it can be
4697
	 * e.g. $wgLang (such as special pages, which are in the user language).
4698
	 *
4699
	 * @since 1.20
4700
	 * @return Language
4701
	 */
4702
	public function getPageViewLanguage() {
4703
		global $wgLang;
4704
4705
		if ( $this->isSpecialPage() ) {
4706
			// If the user chooses a variant, the content is actually
4707
			// in a language whose code is the variant code.
4708
			$variant = $wgLang->getPreferredVariant();
4709
			if ( $wgLang->getCode() !== $variant ) {
4710
				return Language::factory( $variant );
4711
			}
4712
4713
			return $wgLang;
4714
		}
4715
4716
		// Checking if DB language is set
4717
		$dbPageLanguage = $this->getDbPageLanguageCode();
4718
		if ( $dbPageLanguage ) {
4719
			$pageLang = wfGetLangObj( $dbPageLanguage );
4720
			$variant = $pageLang->getPreferredVariant();
4721
			if ( $pageLang->getCode() !== $variant ) {
4722
				$pageLang = Language::factory( $variant );
4723
			}
4724
4725
			return $pageLang;
4726
		}
4727
4728
		// @note Can't be cached persistently, depends on user settings.
4729
		// @note ContentHandler::getPageViewLanguage() may need to load the
4730
		//   content to determine the page language!
4731
		$contentHandler = ContentHandler::getForTitle( $this );
4732
		$pageLang = $contentHandler->getPageViewLanguage( $this );
4733
		return $pageLang;
4734
	}
4735
4736
	/**
4737
	 * Get a list of rendered edit notices for this page.
4738
	 *
4739
	 * Array is keyed by the original message key, and values are rendered using parseAsBlock, so
4740
	 * they will already be wrapped in paragraphs.
4741
	 *
4742
	 * @since 1.21
4743
	 * @param int $oldid Revision ID that's being edited
4744
	 * @return array
4745
	 */
4746
	public function getEditNotices( $oldid = 0 ) {
4747
		$notices = [];
4748
4749
		// Optional notice for the entire namespace
4750
		$editnotice_ns = 'editnotice-' . $this->getNamespace();
4751
		$msg = wfMessage( $editnotice_ns );
4752 View Code Duplication
		if ( $msg->exists() ) {
4753
			$html = $msg->parseAsBlock();
4754
			// Edit notices may have complex logic, but output nothing (T91715)
4755
			if ( trim( $html ) !== '' ) {
4756
				$notices[$editnotice_ns] = Html::rawElement(
4757
					'div',
4758
					[ 'class' => [
4759
						'mw-editnotice',
4760
						'mw-editnotice-namespace',
4761
						Sanitizer::escapeClass( "mw-$editnotice_ns" )
4762
					] ],
4763
					$html
4764
				);
4765
			}
4766
		}
4767
4768
		if ( MWNamespace::hasSubpages( $this->getNamespace() ) ) {
4769
			// Optional notice for page itself and any parent page
4770
			$parts = explode( '/', $this->getDBkey() );
4771
			$editnotice_base = $editnotice_ns;
4772
			while ( count( $parts ) > 0 ) {
4773
				$editnotice_base .= '-' . array_shift( $parts );
4774
				$msg = wfMessage( $editnotice_base );
4775 View Code Duplication
				if ( $msg->exists() ) {
4776
					$html = $msg->parseAsBlock();
4777
					if ( trim( $html ) !== '' ) {
4778
						$notices[$editnotice_base] = Html::rawElement(
4779
							'div',
4780
							[ 'class' => [
4781
								'mw-editnotice',
4782
								'mw-editnotice-base',
4783
								Sanitizer::escapeClass( "mw-$editnotice_base" )
4784
							] ],
4785
							$html
4786
						);
4787
					}
4788
				}
4789
			}
4790
		} else {
4791
			// Even if there are no subpages in namespace, we still don't want "/" in MediaWiki message keys
4792
			$editnoticeText = $editnotice_ns . '-' . strtr( $this->getDBkey(), '/', '-' );
4793
			$msg = wfMessage( $editnoticeText );
4794 View Code Duplication
			if ( $msg->exists() ) {
4795
				$html = $msg->parseAsBlock();
4796
				if ( trim( $html ) !== '' ) {
4797
					$notices[$editnoticeText] = Html::rawElement(
4798
						'div',
4799
						[ 'class' => [
4800
							'mw-editnotice',
4801
							'mw-editnotice-page',
4802
							Sanitizer::escapeClass( "mw-$editnoticeText" )
4803
						] ],
4804
						$html
4805
					);
4806
				}
4807
			}
4808
		}
4809
4810
		Hooks::run( 'TitleGetEditNotices', [ $this, $oldid, &$notices ] );
4811
		return $notices;
4812
	}
4813
4814
	/**
4815
	 * @return array
4816
	 */
4817
	public function __sleep() {
4818
		return [
4819
			'mNamespace',
4820
			'mDbkeyform',
4821
			'mFragment',
4822
			'mInterwiki',
4823
			'mLocalInterwiki',
4824
			'mUserCaseDBKey',
4825
			'mDefaultNamespace',
4826
		];
4827
	}
4828
4829
	public function __wakeup() {
4830
		$this->mArticleID = ( $this->mNamespace >= 0 ) ? -1 : 0;
4831
		$this->mUrlform = wfUrlencode( $this->mDbkeyform );
4832
		$this->mTextform = strtr( $this->mDbkeyform, '_', ' ' );
4833
	}
4834
4835
}
4836