1<?php 2/** 3 * Preparation for the final page rendering. 4 * 5 * This program is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation; either version 2 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License along 16 * with this program; if not, write to the Free Software Foundation, Inc., 17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 * http://www.gnu.org/copyleft/gpl.html 19 * 20 * @file 21 */ 22 23use MediaWiki\HookContainer\ProtectedHookAccessorTrait; 24use MediaWiki\Linker\LinkTarget; 25use MediaWiki\MediaWikiServices; 26use MediaWiki\Page\PageRecord; 27use MediaWiki\Page\PageReference; 28use MediaWiki\Permissions\PermissionStatus; 29use MediaWiki\Session\SessionManager; 30use Wikimedia\Rdbms\IResultWrapper; 31use Wikimedia\RelPath; 32use Wikimedia\WrappedString; 33use Wikimedia\WrappedStringList; 34 35/** 36 * This is one of the Core classes and should 37 * be read at least once by any new developers. Also documented at 38 * https://www.mediawiki.org/wiki/Manual:Architectural_modules/OutputPage 39 * 40 * This class is used to prepare the final rendering. A skin is then 41 * applied to the output parameters (links, javascript, html, categories ...). 42 * 43 * @todo FIXME: Another class handles sending the whole page to the client. 44 * 45 * Some comments comes from a pairing session between Zak Greant and Antoine Musso 46 * in November 2010. 47 * 48 * @todo document 49 */ 50class OutputPage extends ContextSource { 51 use ProtectedHookAccessorTrait; 52 53 /** @var string[][] Should be private. Used with addMeta() which adds "<meta>" */ 54 protected $mMetatags = []; 55 56 /** @var array */ 57 protected $mLinktags = []; 58 59 /** @var string|bool */ 60 protected $mCanonicalUrl = false; 61 62 /** 63 * @var string The contents of <h1> 64 */ 65 private $mPageTitle = ''; 66 67 /** 68 * @var string The displayed title of the page. Different from page title 69 * if overridden by display title magic word or hooks. Can contain safe 70 * HTML. Different from page title which may contain messages such as 71 * "Editing X" which is displayed in h1. This can be used for other places 72 * where the page name is referred on the page. 73 */ 74 private $displayTitle; 75 76 /** @var bool See OutputPage::couldBePublicCached. */ 77 private $cacheIsFinal = false; 78 79 /** 80 * @var string Contains all of the "<body>" content. Should be private we 81 * got set/get accessors and the append() method. 82 */ 83 public $mBodytext = ''; 84 85 /** @var string Stores contents of "<title>" tag */ 86 private $mHTMLtitle = ''; 87 88 /** 89 * @var bool Is the displayed content related to the source of the 90 * corresponding wiki article. 91 */ 92 private $mIsArticle = false; 93 94 /** @var bool Stores "article flag" toggle. */ 95 private $mIsArticleRelated = true; 96 97 /** @var bool Is the content subject to copyright */ 98 private $mHasCopyright = false; 99 100 /** 101 * @var bool We have to set isPrintable(). Some pages should 102 * never be printed (ex: redirections). 103 */ 104 private $mPrintable = false; 105 106 /** 107 * @var array Contains the page subtitle. Special pages usually have some 108 * links here. Don't confuse with site subtitle added by skins. 109 */ 110 private $mSubtitle = []; 111 112 /** @var string */ 113 public $mRedirect = ''; 114 115 /** @var int */ 116 protected $mStatusCode; 117 118 /** 119 * @var string Used for sending cache control. 120 * The whole caching system should probably be moved into its own class. 121 */ 122 protected $mLastModified = ''; 123 124 /** @var string[][] */ 125 protected $mCategoryLinks = []; 126 127 /** @var string[][] */ 128 protected $mCategories = [ 129 'hidden' => [], 130 'normal' => [], 131 ]; 132 133 /** @var string[] */ 134 protected $mIndicators = []; 135 136 /** @var array Array of Interwiki Prefixed (non DB key) Titles (e.g. 'fr:Test page') */ 137 private $mLanguageLinks = []; 138 139 /** 140 * Used for JavaScript (predates ResourceLoader) 141 * @todo We should split JS / CSS. 142 * mScripts content is inserted as is in "<head>" by Skin. This might 143 * contain either a link to a stylesheet or inline CSS. 144 */ 145 private $mScripts = ''; 146 147 /** @var string Inline CSS styles. Use addInlineStyle() sparingly */ 148 protected $mInlineStyles = ''; 149 150 /** 151 * @var string Used by skin template. 152 * Example: $tpl->set( 'displaytitle', $out->mPageLinkTitle ); 153 */ 154 public $mPageLinkTitle = ''; 155 156 /** 157 * Additional <html> classes; This should be rarely modified; prefer mAdditionalBodyClasses. 158 * @var array 159 */ 160 protected $mAdditionalHtmlClasses = []; 161 162 /** @var array Array of elements in "<head>". Parser might add its own headers! */ 163 protected $mHeadItems = []; 164 165 /** @var array Additional <body> classes; there are also <body> classes from other sources */ 166 protected $mAdditionalBodyClasses = []; 167 168 /** @var array */ 169 protected $mModules = []; 170 171 /** @var array */ 172 protected $mModuleStyles = []; 173 174 /** @var ResourceLoader */ 175 protected $mResourceLoader; 176 177 /** @var ResourceLoaderClientHtml */ 178 private $rlClient; 179 180 /** @var ResourceLoaderContext */ 181 private $rlClientContext; 182 183 /** @var array */ 184 private $rlExemptStyleModules; 185 186 /** @var array */ 187 protected $mJsConfigVars = []; 188 189 /** @var array */ 190 protected $mTemplateIds = []; 191 192 /** @var array */ 193 protected $mImageTimeKeys = []; 194 195 /** @var string */ 196 public $mRedirectCode = ''; 197 198 protected $mFeedLinksAppendQuery = null; 199 200 /** @var array 201 * What level of 'untrustworthiness' is allowed in CSS/JS modules loaded on this page? 202 * @see ResourceLoaderModule::$origin 203 * ResourceLoaderModule::ORIGIN_ALL is assumed unless overridden; 204 */ 205 protected $mAllowedModules = [ 206 ResourceLoaderModule::TYPE_COMBINED => ResourceLoaderModule::ORIGIN_ALL, 207 ]; 208 209 /** @var bool Whether output is disabled. If this is true, the 'output' method will do nothing. */ 210 protected $mDoNothing = false; 211 212 // Parser related. 213 214 /** @var int */ 215 protected $mContainsNewMagic = 0; 216 217 /** 218 * lazy initialised, use parserOptions() 219 * @var ParserOptions 220 */ 221 protected $mParserOptions = null; 222 223 /** 224 * Handles the Atom / RSS links. 225 * We probably only support Atom in 2011. 226 * @see $wgAdvertisedFeedTypes 227 */ 228 private $mFeedLinks = []; 229 230 /** @var bool Gwicke work on squid caching? Roughly from 2003. */ 231 protected $mEnableClientCache = true; 232 233 /** @var bool Flag if output should only contain the body of the article. */ 234 private $mArticleBodyOnly = false; 235 236 /** @var bool */ 237 protected $mNewSectionLink = false; 238 239 /** @var bool */ 240 protected $mHideNewSectionLink = false; 241 242 /** 243 * @var bool Comes from the parser. This was probably made to load CSS/JS 244 * only if we had "<gallery>". Used directly in CategoryPage.php. 245 * Looks like ResourceLoader can replace this. 246 */ 247 public $mNoGallery = false; 248 249 /** @var int Cache stuff. Looks like mEnableClientCache */ 250 protected $mCdnMaxage = 0; 251 /** @var int Upper limit on mCdnMaxage */ 252 protected $mCdnMaxageLimit = INF; 253 254 /** 255 * @var bool Controls if anti-clickjacking / frame-breaking headers will 256 * be sent. This should be done for pages where edit actions are possible. 257 * Setters: $this->preventClickjacking() and $this->allowClickjacking(). 258 */ 259 protected $mPreventClickjacking = true; 260 261 /** @var int|null To include the variable {{REVISIONID}} */ 262 private $mRevisionId = null; 263 264 /** @var string */ 265 private $mRevisionTimestamp = null; 266 267 /** @var array */ 268 protected $mFileVersion = null; 269 270 /** 271 * @var array An array of stylesheet filenames (relative from skins path), 272 * with options for CSS media, IE conditions, and RTL/LTR direction. 273 * For internal use; add settings in the skin via $this->addStyle() 274 * 275 * Style again! This seems like a code duplication since we already have 276 * mStyles. This is what makes Open Source amazing. 277 */ 278 protected $styles = []; 279 280 private $mIndexPolicy = 'index'; 281 private $mFollowPolicy = 'follow'; 282 283 /** 284 * @var array Headers that cause the cache to vary. Key is header name, 285 * value should always be null. (Value was an array of options for 286 * the `Key` header, which was deprecated in 1.32 and removed in 1.34.) 287 */ 288 private $mVaryHeader = [ 289 'Accept-Encoding' => null, 290 ]; 291 292 /** 293 * If the current page was reached through a redirect, $mRedirectedFrom contains the title 294 * of the redirect. 295 * 296 * @var PageReference 297 */ 298 private $mRedirectedFrom = null; 299 300 /** 301 * Additional key => value data 302 */ 303 private $mProperties = []; 304 305 /** 306 * @var string|null ResourceLoader target for load.php links. If null, will be omitted 307 */ 308 private $mTarget = null; 309 310 /** 311 * @var bool Whether parser output contains a table of contents 312 */ 313 private $mEnableTOC = false; 314 315 /** 316 * @var string|null The URL to send in a <link> element with rel=license 317 */ 318 private $copyrightUrl; 319 320 /** @var array Profiling data */ 321 private $limitReportJSData = []; 322 323 /** @var array Map Title to Content */ 324 private $contentOverrides = []; 325 326 /** @var callable[] */ 327 private $contentOverrideCallbacks = []; 328 329 /** 330 * Link: header contents 331 */ 332 private $mLinkHeader = []; 333 334 /** 335 * @var ContentSecurityPolicy 336 */ 337 private $CSP; 338 339 /** 340 * @var array A cache of the names of the cookies that will influence the cache 341 */ 342 private static $cacheVaryCookies = null; 343 344 /** 345 * Constructor for OutputPage. This should not be called directly. 346 * Instead a new RequestContext should be created and it will implicitly create 347 * a OutputPage tied to that context. 348 * @param IContextSource $context 349 */ 350 public function __construct( IContextSource $context ) { 351 $this->setContext( $context ); 352 $this->CSP = new ContentSecurityPolicy( 353 $context->getRequest()->response(), 354 $context->getConfig(), 355 $this->getHookContainer() 356 ); 357 } 358 359 /** 360 * Redirect to $url rather than displaying the normal page 361 * 362 * @param string $url 363 * @param string|int $responsecode HTTP status code 364 */ 365 public function redirect( $url, $responsecode = '302' ) { 366 # Strip newlines as a paranoia check for header injection in PHP<5.1.2 367 $this->mRedirect = str_replace( "\n", '', $url ); 368 $this->mRedirectCode = (string)$responsecode; 369 } 370 371 /** 372 * Get the URL to redirect to, or an empty string if not redirect URL set 373 * 374 * @return string 375 */ 376 public function getRedirect() { 377 return $this->mRedirect; 378 } 379 380 /** 381 * Set the copyright URL to send with the output. 382 * Empty string to omit, null to reset. 383 * 384 * @since 1.26 385 * 386 * @param string|null $url 387 */ 388 public function setCopyrightUrl( $url ) { 389 $this->copyrightUrl = $url; 390 } 391 392 /** 393 * Set the HTTP status code to send with the output. 394 * 395 * @param int $statusCode 396 */ 397 public function setStatusCode( $statusCode ) { 398 $this->mStatusCode = $statusCode; 399 } 400 401 /** 402 * Add a new "<meta>" tag 403 * To add an http-equiv meta tag, precede the name with "http:" 404 * 405 * @param string $name Name of the meta tag 406 * @param string $val Value of the meta tag 407 */ 408 public function addMeta( $name, $val ) { 409 $this->mMetatags[] = [ $name, $val ]; 410 } 411 412 /** 413 * Returns the current <meta> tags 414 * 415 * @since 1.25 416 * @return array 417 */ 418 public function getMetaTags() { 419 return $this->mMetatags; 420 } 421 422 /** 423 * Add a new \<link\> tag to the page header. 424 * 425 * Note: use setCanonicalUrl() for rel=canonical. 426 * 427 * @param array $linkarr Associative array of attributes. 428 */ 429 public function addLink( array $linkarr ) { 430 $this->mLinktags[] = $linkarr; 431 } 432 433 /** 434 * Returns the current <link> tags 435 * 436 * @since 1.25 437 * @return array 438 */ 439 public function getLinkTags() { 440 return $this->mLinktags; 441 } 442 443 /** 444 * Set the URL to be used for the <link rel=canonical>. This should be used 445 * in preference to addLink(), to avoid duplicate link tags. 446 * @param string $url 447 */ 448 public function setCanonicalUrl( $url ) { 449 $this->mCanonicalUrl = $url; 450 } 451 452 /** 453 * Returns the URL to be used for the <link rel=canonical> if 454 * one is set. 455 * 456 * @since 1.25 457 * @return bool|string 458 */ 459 public function getCanonicalUrl() { 460 return $this->mCanonicalUrl; 461 } 462 463 /** 464 * Add raw HTML to the list of scripts (including \<script\> tag, etc.) 465 * Internal use only. Use OutputPage::addModules() or OutputPage::addJsConfigVars() 466 * if possible. 467 * 468 * @param string $script Raw HTML 469 */ 470 public function addScript( $script ) { 471 $this->mScripts .= $script; 472 } 473 474 /** 475 * Add a JavaScript file to be loaded as `<script>` on this page. 476 * 477 * Internal use only. Use OutputPage::addModules() if possible. 478 * 479 * @param string $file URL to file (absolute path, protocol-relative, or full url) 480 * @param string|null $unused Previously used to change the cache-busting query parameter 481 */ 482 public function addScriptFile( $file, $unused = null ) { 483 $this->addScript( Html::linkedScript( $file, $this->CSP->getNonce() ) ); 484 } 485 486 /** 487 * Add a self-contained script tag with the given contents 488 * Internal use only. Use OutputPage::addModules() if possible. 489 * 490 * @param string $script JavaScript text, no script tags 491 */ 492 public function addInlineScript( $script ) { 493 $this->mScripts .= Html::inlineScript( "\n$script\n", $this->CSP->getNonce() ) . "\n"; 494 } 495 496 /** 497 * Filter an array of modules to remove insufficiently trustworthy members, and modules 498 * which are no longer registered (eg a page is cached before an extension is disabled) 499 * @param string[] $modules 500 * @param string|null $position Unused 501 * @param string $type 502 * @return string[] 503 */ 504 protected function filterModules( array $modules, $position = null, 505 $type = ResourceLoaderModule::TYPE_COMBINED 506 ) { 507 $resourceLoader = $this->getResourceLoader(); 508 $filteredModules = []; 509 foreach ( $modules as $val ) { 510 $module = $resourceLoader->getModule( $val ); 511 if ( $module instanceof ResourceLoaderModule 512 && $module->getOrigin() <= $this->getAllowedModules( $type ) 513 ) { 514 if ( $this->mTarget && !in_array( $this->mTarget, $module->getTargets() ) ) { 515 $this->warnModuleTargetFilter( $module->getName() ); 516 continue; 517 } 518 $filteredModules[] = $val; 519 } 520 } 521 return $filteredModules; 522 } 523 524 private function warnModuleTargetFilter( $moduleName ) { 525 static $warnings = []; 526 if ( isset( $warnings[$this->mTarget][$moduleName] ) ) { 527 return; 528 } 529 $warnings[$this->mTarget][$moduleName] = true; 530 $this->getResourceLoader()->getLogger()->debug( 531 'Module "{module}" not loadable on target "{target}".', 532 [ 533 'module' => $moduleName, 534 'target' => $this->mTarget, 535 ] 536 ); 537 } 538 539 /** 540 * Get the list of modules to include on this page 541 * 542 * @param bool $filter Whether to filter out insufficiently trustworthy modules 543 * @param string|null $position Unused 544 * @param string $param 545 * @param string $type 546 * @return string[] Array of module names 547 */ 548 public function getModules( $filter = false, $position = null, $param = 'mModules', 549 $type = ResourceLoaderModule::TYPE_COMBINED 550 ) { 551 $modules = array_values( array_unique( $this->$param ) ); 552 return $filter 553 ? $this->filterModules( $modules, null, $type ) 554 : $modules; 555 } 556 557 /** 558 * Load one or more ResourceLoader modules on this page. 559 * 560 * @param string|array $modules Module name (string) or array of module names 561 */ 562 public function addModules( $modules ) { 563 $this->mModules = array_merge( $this->mModules, (array)$modules ); 564 } 565 566 /** 567 * Get the list of style-only modules to load on this page. 568 * 569 * @param bool $filter 570 * @param string|null $position Unused 571 * @return string[] Array of module names 572 */ 573 public function getModuleStyles( $filter = false, $position = null ) { 574 return $this->getModules( $filter, null, 'mModuleStyles', 575 ResourceLoaderModule::TYPE_STYLES 576 ); 577 } 578 579 /** 580 * Load the styles of one or more style-only ResourceLoader modules on this page. 581 * 582 * Module styles added through this function will be loaded as a stylesheet, 583 * using a standard `<link rel=stylesheet>` HTML tag, rather than as a combined 584 * Javascript and CSS package. Thus, they will even load when JavaScript is disabled. 585 * 586 * @param string|array $modules Module name (string) or array of module names 587 */ 588 public function addModuleStyles( $modules ) { 589 $this->mModuleStyles = array_merge( $this->mModuleStyles, (array)$modules ); 590 } 591 592 /** 593 * @return null|string ResourceLoader target 594 */ 595 public function getTarget() { 596 return $this->mTarget; 597 } 598 599 /** 600 * Sets ResourceLoader target for load.php links. If null, will be omitted 601 * 602 * @param string|null $target 603 */ 604 public function setTarget( $target ) { 605 $this->mTarget = $target; 606 } 607 608 /** 609 * Force the given Content object for the given page, for things like page preview. 610 * @see self::addContentOverrideCallback() 611 * @since 1.32 612 * @param LinkTarget|PageReference $target 613 * @param Content $content 614 */ 615 public function addContentOverride( $target, Content $content ) { 616 if ( !$this->contentOverrides ) { 617 // Register a callback for $this->contentOverrides on the first call 618 $this->addContentOverrideCallback( function ( $target ) { 619 $key = $target->getNamespace() . ':' . $target->getDBkey(); 620 return $this->contentOverrides[$key] ?? null; 621 } ); 622 } 623 624 $key = $target->getNamespace() . ':' . $target->getDBkey(); 625 $this->contentOverrides[$key] = $content; 626 } 627 628 /** 629 * Add a callback for mapping from a Title to a Content object, for things 630 * like page preview. 631 * @see ResourceLoaderContext::getContentOverrideCallback() 632 * @since 1.32 633 * @param callable $callback 634 */ 635 public function addContentOverrideCallback( callable $callback ) { 636 $this->contentOverrideCallbacks[] = $callback; 637 } 638 639 /** 640 * Add a class to the <html> element. This should rarely be used. 641 * Instead use OutputPage::addBodyClasses() if possible. 642 * 643 * @unstable Experimental since 1.35. Prefer OutputPage::addBodyClasses() 644 * @param string|string[] $classes One or more classes to add 645 */ 646 public function addHtmlClasses( $classes ) { 647 $this->mAdditionalHtmlClasses = array_merge( $this->mAdditionalHtmlClasses, (array)$classes ); 648 } 649 650 /** 651 * Get an array of head items 652 * 653 * @return array 654 */ 655 public function getHeadItemsArray() { 656 return $this->mHeadItems; 657 } 658 659 /** 660 * Add or replace a head item to the output 661 * 662 * Whenever possible, use more specific options like ResourceLoader modules, 663 * OutputPage::addLink(), OutputPage::addMeta() and OutputPage::addFeedLink() 664 * Fallback options for those are: OutputPage::addStyle, OutputPage::addScript(), 665 * OutputPage::addInlineScript() and OutputPage::addInlineStyle() 666 * This would be your very LAST fallback. 667 * 668 * @param string $name Item name 669 * @param string $value Raw HTML 670 */ 671 public function addHeadItem( $name, $value ) { 672 $this->mHeadItems[$name] = $value; 673 } 674 675 /** 676 * Add one or more head items to the output 677 * 678 * @since 1.28 679 * @param string|string[] $values Raw HTML 680 */ 681 public function addHeadItems( $values ) { 682 $this->mHeadItems = array_merge( $this->mHeadItems, (array)$values ); 683 } 684 685 /** 686 * Check if the header item $name is already set 687 * 688 * @param string $name Item name 689 * @return bool 690 */ 691 public function hasHeadItem( $name ) { 692 return isset( $this->mHeadItems[$name] ); 693 } 694 695 /** 696 * Add a class to the <body> element 697 * 698 * @since 1.30 699 * @param string|string[] $classes One or more classes to add 700 */ 701 public function addBodyClasses( $classes ) { 702 $this->mAdditionalBodyClasses = array_merge( $this->mAdditionalBodyClasses, (array)$classes ); 703 } 704 705 /** 706 * Set whether the output should only contain the body of the article, 707 * without any skin, sidebar, etc. 708 * Used e.g. when calling with "action=render". 709 * 710 * @param bool $only Whether to output only the body of the article 711 */ 712 public function setArticleBodyOnly( $only ) { 713 $this->mArticleBodyOnly = $only; 714 } 715 716 /** 717 * Return whether the output will contain only the body of the article 718 * 719 * @return bool 720 */ 721 public function getArticleBodyOnly() { 722 return $this->mArticleBodyOnly; 723 } 724 725 /** 726 * Set an additional output property 727 * @since 1.21 728 * 729 * @param string $name 730 * @param mixed $value 731 */ 732 public function setProperty( $name, $value ) { 733 $this->mProperties[$name] = $value; 734 } 735 736 /** 737 * Get an additional output property 738 * @since 1.21 739 * 740 * @param string $name 741 * @return mixed Property value or null if not found 742 */ 743 public function getProperty( $name ) { 744 return $this->mProperties[$name] ?? null; 745 } 746 747 /** 748 * checkLastModified tells the client to use the client-cached page if 749 * possible. If successful, the OutputPage is disabled so that 750 * any future call to OutputPage->output() have no effect. 751 * 752 * Side effect: sets mLastModified for Last-Modified header 753 * 754 * @param string $timestamp 755 * 756 * @return bool True if cache-ok headers was sent. 757 */ 758 public function checkLastModified( $timestamp ) { 759 if ( !$timestamp || $timestamp == '19700101000000' ) { 760 wfDebug( __METHOD__ . ": CACHE DISABLED, NO TIMESTAMP" ); 761 return false; 762 } 763 $config = $this->getConfig(); 764 if ( !$config->get( 'CachePages' ) ) { 765 wfDebug( __METHOD__ . ": CACHE DISABLED" ); 766 return false; 767 } 768 769 $timestamp = wfTimestamp( TS_MW, $timestamp ); 770 $modifiedTimes = [ 771 'page' => $timestamp, 772 'user' => $this->getUser()->getTouched(), 773 'epoch' => $config->get( 'CacheEpoch' ) 774 ]; 775 if ( $config->get( 'UseCdn' ) ) { 776 $modifiedTimes['sepoch'] = wfTimestamp( TS_MW, $this->getCdnCacheEpoch( 777 time(), 778 $config->get( 'CdnMaxAge' ) 779 ) ); 780 } 781 $this->getHookRunner()->onOutputPageCheckLastModified( $modifiedTimes, $this ); 782 783 $maxModified = max( $modifiedTimes ); 784 $this->mLastModified = wfTimestamp( TS_RFC2822, $maxModified ); 785 786 $clientHeader = $this->getRequest()->getHeader( 'If-Modified-Since' ); 787 if ( $clientHeader === false ) { 788 wfDebug( __METHOD__ . ": client did not send If-Modified-Since header", 'private' ); 789 return false; 790 } 791 792 # IE sends sizes after the date like this: 793 # Wed, 20 Aug 2003 06:51:19 GMT; length=5202 794 # this breaks strtotime(). 795 $clientHeader = preg_replace( '/;.*$/', '', $clientHeader ); 796 797 Wikimedia\suppressWarnings(); // E_STRICT system time warnings 798 $clientHeaderTime = strtotime( $clientHeader ); 799 Wikimedia\restoreWarnings(); 800 if ( !$clientHeaderTime ) { 801 wfDebug( __METHOD__ 802 . ": unable to parse the client's If-Modified-Since header: $clientHeader" ); 803 return false; 804 } 805 $clientHeaderTime = wfTimestamp( TS_MW, $clientHeaderTime ); 806 807 # Make debug info 808 $info = ''; 809 foreach ( $modifiedTimes as $name => $value ) { 810 if ( $info !== '' ) { 811 $info .= ', '; 812 } 813 $info .= "$name=" . wfTimestamp( TS_ISO_8601, $value ); 814 } 815 816 wfDebug( __METHOD__ . ": client sent If-Modified-Since: " . 817 wfTimestamp( TS_ISO_8601, $clientHeaderTime ), 'private' ); 818 wfDebug( __METHOD__ . ": effective Last-Modified: " . 819 wfTimestamp( TS_ISO_8601, $maxModified ), 'private' ); 820 if ( $clientHeaderTime < $maxModified ) { 821 wfDebug( __METHOD__ . ": STALE, $info", 'private' ); 822 return false; 823 } 824 825 # Not modified 826 # Give a 304 Not Modified response code and disable body output 827 wfDebug( __METHOD__ . ": NOT MODIFIED, $info", 'private' ); 828 ini_set( 'zlib.output_compression', 0 ); 829 $this->getRequest()->response()->statusHeader( 304 ); 830 $this->sendCacheControl(); 831 $this->disable(); 832 833 // Don't output a compressed blob when using ob_gzhandler; 834 // it's technically against HTTP spec and seems to confuse 835 // Firefox when the response gets split over two packets. 836 wfResetOutputBuffers( false ); 837 838 return true; 839 } 840 841 /** 842 * @param int $reqTime Time of request (eg. now) 843 * @param int $maxAge Cache TTL in seconds 844 * @return int Timestamp 845 */ 846 private function getCdnCacheEpoch( $reqTime, $maxAge ) { 847 // Ensure Last-Modified is never more than $wgCdnMaxAge in the past, 848 // because even if the wiki page content hasn't changed since, static 849 // resources may have changed (skin HTML, interface messages, urls, etc.) 850 // and must roll-over in a timely manner (T46570) 851 return $reqTime - $maxAge; 852 } 853 854 /** 855 * Override the last modified timestamp 856 * 857 * @param string $timestamp New timestamp, in a format readable by 858 * wfTimestamp() 859 */ 860 public function setLastModified( $timestamp ) { 861 $this->mLastModified = wfTimestamp( TS_RFC2822, $timestamp ); 862 } 863 864 /** 865 * Set the robot policy for the page: <http://www.robotstxt.org/meta.html> 866 * 867 * @param string $policy The literal string to output as the contents of 868 * the meta tag. Will be parsed according to the spec and output in 869 * standardized form. 870 */ 871 public function setRobotPolicy( $policy ) { 872 $policy = Article::formatRobotPolicy( $policy ); 873 874 if ( isset( $policy['index'] ) ) { 875 $this->setIndexPolicy( $policy['index'] ); 876 } 877 if ( isset( $policy['follow'] ) ) { 878 $this->setFollowPolicy( $policy['follow'] ); 879 } 880 } 881 882 /** 883 * Get the current robot policy for the page as a string in the form 884 * <index policy>,<follow policy>. 885 * 886 * @return string 887 */ 888 public function getRobotPolicy() { 889 return "{$this->mIndexPolicy},{$this->mFollowPolicy}"; 890 } 891 892 /** 893 * Set the index policy for the page, but leave the follow policy un- 894 * touched. 895 * 896 * @param string $policy Either 'index' or 'noindex'. 897 */ 898 public function setIndexPolicy( $policy ) { 899 $policy = trim( $policy ); 900 if ( in_array( $policy, [ 'index', 'noindex' ] ) ) { 901 $this->mIndexPolicy = $policy; 902 } 903 } 904 905 /** 906 * Get the current index policy for the page as a string. 907 * 908 * @return string 909 */ 910 public function getIndexPolicy() { 911 return $this->mIndexPolicy; 912 } 913 914 /** 915 * Set the follow policy for the page, but leave the index policy un- 916 * touched. 917 * 918 * @param string $policy Either 'follow' or 'nofollow'. 919 */ 920 public function setFollowPolicy( $policy ) { 921 $policy = trim( $policy ); 922 if ( in_array( $policy, [ 'follow', 'nofollow' ] ) ) { 923 $this->mFollowPolicy = $policy; 924 } 925 } 926 927 /** 928 * Get the current follow policy for the page as a string. 929 * 930 * @return string 931 */ 932 public function getFollowPolicy() { 933 return $this->mFollowPolicy; 934 } 935 936 /** 937 * "HTML title" means the contents of "<title>". 938 * It is stored as plain, unescaped text and will be run through htmlspecialchars in the skin file. 939 * 940 * @param string|Message $name 941 */ 942 public function setHTMLTitle( $name ) { 943 if ( $name instanceof Message ) { 944 $this->mHTMLtitle = $name->setContext( $this->getContext() )->text(); 945 } else { 946 $this->mHTMLtitle = $name; 947 } 948 } 949 950 /** 951 * Return the "HTML title", i.e. the content of the "<title>" tag. 952 * 953 * @return string 954 */ 955 public function getHTMLTitle() { 956 return $this->mHTMLtitle; 957 } 958 959 /** 960 * Set $mRedirectedFrom, the page which redirected us to the current page. 961 * 962 * @param PageReference $t 963 */ 964 public function setRedirectedFrom( PageReference $t ) { 965 $this->mRedirectedFrom = $t; 966 } 967 968 /** 969 * "Page title" means the contents of \<h1\>. It is stored as a valid HTML 970 * fragment. This function allows good tags like \<sup\> in the \<h1\> tag, 971 * but not bad tags like \<script\>. This function automatically sets 972 * \<title\> to the same content as \<h1\> but with all tags removed. Bad 973 * tags that were escaped in \<h1\> will still be escaped in \<title\>, and 974 * good tags like \<i\> will be dropped entirely. 975 * 976 * @param string|Message $name 977 * @param-taint $name tainted 978 * Phan-taint-check gets very confused by $name being either a string or a Message 979 */ 980 public function setPageTitle( $name ) { 981 if ( $name instanceof Message ) { 982 $name = $name->setContext( $this->getContext() )->text(); 983 } 984 985 # change "<script>foo&bar</script>" to "<script>foo&bar</script>" 986 # but leave "<i>foobar</i>" alone 987 $nameWithTags = Sanitizer::normalizeCharReferences( Sanitizer::removeHTMLtags( $name ) ); 988 $this->mPageTitle = $nameWithTags; 989 990 # change "<i>foo&bar</i>" to "foo&bar" 991 $this->setHTMLTitle( 992 $this->msg( 'pagetitle' )->plaintextParams( Sanitizer::stripAllTags( $nameWithTags ) ) 993 ->inContentLanguage() 994 ); 995 } 996 997 /** 998 * Return the "page title", i.e. the content of the \<h1\> tag. 999 * 1000 * @return string 1001 */ 1002 public function getPageTitle() { 1003 return $this->mPageTitle; 1004 } 1005 1006 /** 1007 * Same as page title but only contains name of the page, not any other text. 1008 * 1009 * @since 1.32 1010 * @param string $html Page title text. 1011 * @see OutputPage::setPageTitle 1012 */ 1013 public function setDisplayTitle( $html ) { 1014 $this->displayTitle = $html; 1015 } 1016 1017 /** 1018 * Returns page display title. 1019 * 1020 * Performs some normalization, but this not as strict the magic word. 1021 * 1022 * @since 1.32 1023 * @return string HTML 1024 */ 1025 public function getDisplayTitle() { 1026 $html = $this->displayTitle; 1027 if ( $html === null ) { 1028 $html = $this->getTitle()->getPrefixedText(); 1029 } 1030 1031 return Sanitizer::normalizeCharReferences( Sanitizer::removeHTMLtags( $html ) ); 1032 } 1033 1034 /** 1035 * Returns page display title without namespace prefix if possible. 1036 * 1037 * @since 1.32 1038 * @return string HTML 1039 */ 1040 public function getUnprefixedDisplayTitle() { 1041 $text = $this->getDisplayTitle(); 1042 $nsPrefix = $this->getTitle()->getNsText() . ':'; 1043 $prefix = preg_quote( $nsPrefix, '/' ); 1044 1045 return preg_replace( "/^$prefix/i", '', $text ); 1046 } 1047 1048 /** 1049 * Set the Title object to use 1050 * 1051 * @param PageReference $t 1052 */ 1053 public function setTitle( PageReference $t ) { 1054 $t = Title::castFromPageReference( $t ); 1055 1056 // @phan-suppress-next-next-line PhanUndeclaredMethod 1057 // @fixme Not all implementations of IContextSource have this method! 1058 $this->getContext()->setTitle( $t ); 1059 } 1060 1061 /** 1062 * Replace the subtitle with $str 1063 * 1064 * @param string|Message $str New value of the subtitle. String should be safe HTML. 1065 */ 1066 public function setSubtitle( $str ) { 1067 $this->clearSubtitle(); 1068 $this->addSubtitle( $str ); 1069 } 1070 1071 /** 1072 * Add $str to the subtitle 1073 * 1074 * @param string|Message $str String or Message to add to the subtitle. String should be safe HTML. 1075 */ 1076 public function addSubtitle( $str ) { 1077 if ( $str instanceof Message ) { 1078 $this->mSubtitle[] = $str->setContext( $this->getContext() )->parse(); 1079 } else { 1080 $this->mSubtitle[] = $str; 1081 } 1082 } 1083 1084 /** 1085 * Build message object for a subtitle containing a backlink to a page 1086 * 1087 * @param PageReference $page Title to link to 1088 * @param array $query Array of additional parameters to include in the link 1089 * @return Message 1090 * @since 1.25 1091 */ 1092 public static function buildBacklinkSubtitle( PageReference $page, $query = [] ) { 1093 if ( $page instanceof PageRecord || $page instanceof Title ) { 1094 // Callers will typically have a PageRecord 1095 if ( $page->isRedirect() ) { 1096 $query['redirect'] = 'no'; 1097 } 1098 } elseif ( $page->getNamespace() !== NS_SPECIAL ) { 1099 // We don't know whether it's a redirect, so add the parameter, just to be sure. 1100 $query['redirect'] = 'no'; 1101 } 1102 1103 $target = TitleValue::castPageToLinkTarget( $page ); 1104 $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); 1105 return wfMessage( 'backlinksubtitle' ) 1106 ->rawParams( $linkRenderer->makeLink( $target, null, [], $query ) ); 1107 } 1108 1109 /** 1110 * Add a subtitle containing a backlink to a page 1111 * 1112 * @param PageReference $title Title to link to 1113 * @param array $query Array of additional parameters to include in the link 1114 */ 1115 public function addBacklinkSubtitle( PageReference $title, $query = [] ) { 1116 $this->addSubtitle( self::buildBacklinkSubtitle( $title, $query ) ); 1117 } 1118 1119 /** 1120 * Clear the subtitles 1121 */ 1122 public function clearSubtitle() { 1123 $this->mSubtitle = []; 1124 } 1125 1126 /** 1127 * @return string 1128 */ 1129 public function getSubtitle() { 1130 return implode( "<br />\n\t\t\t\t", $this->mSubtitle ); 1131 } 1132 1133 /** 1134 * Set the page as printable, i.e. it'll be displayed with all 1135 * print styles included 1136 */ 1137 public function setPrintable() { 1138 $this->mPrintable = true; 1139 } 1140 1141 /** 1142 * Return whether the page is "printable" 1143 * 1144 * @return bool 1145 */ 1146 public function isPrintable() { 1147 return $this->mPrintable; 1148 } 1149 1150 /** 1151 * Disable output completely, i.e. calling output() will have no effect 1152 */ 1153 public function disable() { 1154 $this->mDoNothing = true; 1155 } 1156 1157 /** 1158 * Return whether the output will be completely disabled 1159 * 1160 * @return bool 1161 */ 1162 public function isDisabled() { 1163 return $this->mDoNothing; 1164 } 1165 1166 /** 1167 * Show an "add new section" link? 1168 * 1169 * @return bool 1170 */ 1171 public function showNewSectionLink() { 1172 return $this->mNewSectionLink; 1173 } 1174 1175 /** 1176 * Forcibly hide the new section link? 1177 * 1178 * @return bool 1179 */ 1180 public function forceHideNewSectionLink() { 1181 return $this->mHideNewSectionLink; 1182 } 1183 1184 /** 1185 * Add or remove feed links in the page header 1186 * This is mainly kept for backward compatibility, see OutputPage::addFeedLink() 1187 * for the new version 1188 * @see addFeedLink() 1189 * 1190 * @param bool $show True: add default feeds, false: remove all feeds 1191 */ 1192 public function setSyndicated( $show = true ) { 1193 if ( $show ) { 1194 $this->setFeedAppendQuery( false ); 1195 } else { 1196 $this->mFeedLinks = []; 1197 } 1198 } 1199 1200 /** 1201 * Return effective list of advertised feed types 1202 * @see addFeedLink() 1203 * 1204 * @return string[] Array of feed type names ( 'rss', 'atom' ) 1205 */ 1206 protected function getAdvertisedFeedTypes() { 1207 if ( $this->getConfig()->get( 'Feed' ) ) { 1208 return $this->getConfig()->get( 'AdvertisedFeedTypes' ); 1209 } else { 1210 return []; 1211 } 1212 } 1213 1214 /** 1215 * Add default feeds to the page header 1216 * This is mainly kept for backward compatibility, see OutputPage::addFeedLink() 1217 * for the new version 1218 * @see addFeedLink() 1219 * 1220 * @param string $val Query to append to feed links or false to output 1221 * default links 1222 */ 1223 public function setFeedAppendQuery( $val ) { 1224 $this->mFeedLinks = []; 1225 1226 foreach ( $this->getAdvertisedFeedTypes() as $type ) { 1227 $query = "feed=$type"; 1228 if ( is_string( $val ) ) { 1229 $query .= '&' . $val; 1230 } 1231 $this->mFeedLinks[$type] = $this->getTitle()->getLocalURL( $query ); 1232 } 1233 } 1234 1235 /** 1236 * Add a feed link to the page header 1237 * 1238 * @param string $format Feed type, should be a key of $wgFeedClasses 1239 * @param string $href URL 1240 */ 1241 public function addFeedLink( $format, $href ) { 1242 if ( in_array( $format, $this->getAdvertisedFeedTypes() ) ) { 1243 $this->mFeedLinks[$format] = $href; 1244 } 1245 } 1246 1247 /** 1248 * Should we output feed links for this page? 1249 * @return bool 1250 */ 1251 public function isSyndicated() { 1252 return count( $this->mFeedLinks ) > 0; 1253 } 1254 1255 /** 1256 * Return URLs for each supported syndication format for this page. 1257 * @return array Associating format keys with URLs 1258 */ 1259 public function getSyndicationLinks() { 1260 return $this->mFeedLinks; 1261 } 1262 1263 /** 1264 * Will currently always return null 1265 * 1266 * @return null 1267 */ 1268 public function getFeedAppendQuery() { 1269 return $this->mFeedLinksAppendQuery; 1270 } 1271 1272 /** 1273 * Set whether the displayed content is related to the source of the 1274 * corresponding article on the wiki 1275 * Setting true will cause the change "article related" toggle to true 1276 * 1277 * @param bool $newVal 1278 */ 1279 public function setArticleFlag( $newVal ) { 1280 $this->mIsArticle = $newVal; 1281 if ( $newVal ) { 1282 $this->mIsArticleRelated = $newVal; 1283 } 1284 } 1285 1286 /** 1287 * Return whether the content displayed page is related to the source of 1288 * the corresponding article on the wiki 1289 * 1290 * @return bool 1291 */ 1292 public function isArticle() { 1293 return $this->mIsArticle; 1294 } 1295 1296 /** 1297 * Set whether this page is related an article on the wiki 1298 * Setting false will cause the change of "article flag" toggle to false 1299 * 1300 * @param bool $newVal 1301 */ 1302 public function setArticleRelated( $newVal ) { 1303 $this->mIsArticleRelated = $newVal; 1304 if ( !$newVal ) { 1305 $this->mIsArticle = false; 1306 } 1307 } 1308 1309 /** 1310 * Return whether this page is related an article on the wiki 1311 * 1312 * @return bool 1313 */ 1314 public function isArticleRelated() { 1315 return $this->mIsArticleRelated; 1316 } 1317 1318 /** 1319 * Set whether the standard copyright should be shown for the current page. 1320 * 1321 * @param bool $hasCopyright 1322 */ 1323 public function setCopyright( $hasCopyright ) { 1324 $this->mHasCopyright = $hasCopyright; 1325 } 1326 1327 /** 1328 * Return whether the standard copyright should be shown for the current page. 1329 * By default, it is true for all articles but other pages 1330 * can signal it by using setCopyright( true ). 1331 * 1332 * Used by SkinTemplate to decided whether to show the copyright. 1333 * 1334 * @return bool 1335 */ 1336 public function showsCopyright() { 1337 return $this->isArticle() || $this->mHasCopyright; 1338 } 1339 1340 /** 1341 * Add new language links 1342 * 1343 * @param string[] $newLinkArray Array of interwiki-prefixed (non DB key) titles 1344 * (e.g. 'fr:Test page') 1345 */ 1346 public function addLanguageLinks( array $newLinkArray ) { 1347 $this->mLanguageLinks = array_merge( $this->mLanguageLinks, $newLinkArray ); 1348 } 1349 1350 /** 1351 * Reset the language links and add new language links 1352 * 1353 * @param string[] $newLinkArray Array of interwiki-prefixed (non DB key) titles 1354 * (e.g. 'fr:Test page') 1355 */ 1356 public function setLanguageLinks( array $newLinkArray ) { 1357 $this->mLanguageLinks = $newLinkArray; 1358 } 1359 1360 /** 1361 * Get the list of language links 1362 * 1363 * @return string[] Array of interwiki-prefixed (non DB key) titles (e.g. 'fr:Test page') 1364 */ 1365 public function getLanguageLinks() { 1366 return $this->mLanguageLinks; 1367 } 1368 1369 /** 1370 * Add an array of categories, with names in the keys 1371 * 1372 * @param array $categories Mapping category name => sort key 1373 */ 1374 public function addCategoryLinks( array $categories ) { 1375 if ( !$categories ) { 1376 return; 1377 } 1378 1379 $res = $this->addCategoryLinksToLBAndGetResult( $categories ); 1380 1381 # Set all the values to 'normal'. 1382 $categories = array_fill_keys( array_keys( $categories ), 'normal' ); 1383 1384 # Mark hidden categories 1385 foreach ( $res as $row ) { 1386 if ( isset( $row->pp_value ) ) { 1387 $categories[$row->page_title] = 'hidden'; 1388 } 1389 } 1390 1391 # Add the remaining categories to the skin 1392 if ( $this->getHookRunner()->onOutputPageMakeCategoryLinks( 1393 $this, $categories, $this->mCategoryLinks ) 1394 ) { 1395 $services = MediaWikiServices::getInstance(); 1396 $linkRenderer = $services->getLinkRenderer(); 1397 $languageConverter = $services->getLanguageConverterFactory() 1398 ->getLanguageConverter( $services->getContentLanguage() ); 1399 foreach ( $categories as $category => $type ) { 1400 // array keys will cast numeric category names to ints, so cast back to string 1401 $category = (string)$category; 1402 $origcategory = $category; 1403 $title = Title::makeTitleSafe( NS_CATEGORY, $category ); 1404 if ( !$title ) { 1405 continue; 1406 } 1407 $languageConverter->findVariantLink( $category, $title, true ); 1408 1409 if ( $category != $origcategory && array_key_exists( $category, $categories ) ) { 1410 continue; 1411 } 1412 $text = $languageConverter->convertHtml( $title->getText() ); 1413 $this->mCategories[$type][] = $title->getText(); 1414 $this->mCategoryLinks[$type][] = $linkRenderer->makeLink( $title, new HtmlArmor( $text ) ); 1415 } 1416 } 1417 } 1418 1419 /** 1420 * @param array $categories 1421 * @return bool|IResultWrapper 1422 */ 1423 protected function addCategoryLinksToLBAndGetResult( array $categories ) { 1424 # Add the links to a LinkBatch 1425 $arr = [ NS_CATEGORY => $categories ]; 1426 $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory(); 1427 $lb = $linkBatchFactory->newLinkBatch(); 1428 $lb->setArray( $arr ); 1429 1430 # Fetch existence plus the hiddencat property 1431 $dbr = wfGetDB( DB_REPLICA ); 1432 $fields = array_merge( 1433 LinkCache::getSelectFields(), 1434 [ 'page_namespace', 'page_title', 'pp_value' ] 1435 ); 1436 1437 $res = $dbr->select( [ 'page', 'page_props' ], 1438 $fields, 1439 $lb->constructSet( 'page', $dbr ), 1440 __METHOD__, 1441 [], 1442 [ 'page_props' => [ 'LEFT JOIN', [ 1443 'pp_propname' => 'hiddencat', 1444 'pp_page = page_id' 1445 ] ] ] 1446 ); 1447 1448 # Add the results to the link cache 1449 $linkCache = MediaWikiServices::getInstance()->getLinkCache(); 1450 $lb->addResultToCache( $linkCache, $res ); 1451 1452 return $res; 1453 } 1454 1455 /** 1456 * Reset the category links (but not the category list) and add $categories 1457 * 1458 * @param array $categories Mapping category name => sort key 1459 */ 1460 public function setCategoryLinks( array $categories ) { 1461 $this->mCategoryLinks = []; 1462 $this->addCategoryLinks( $categories ); 1463 } 1464 1465 /** 1466 * Get the list of category links, in a 2-D array with the following format: 1467 * $arr[$type][] = $link, where $type is either "normal" or "hidden" (for 1468 * hidden categories) and $link a HTML fragment with a link to the category 1469 * page 1470 * 1471 * @return string[][] 1472 */ 1473 public function getCategoryLinks() { 1474 return $this->mCategoryLinks; 1475 } 1476 1477 /** 1478 * Get the list of category names this page belongs to. 1479 * 1480 * @param string $type The type of categories which should be returned. Possible values: 1481 * * all: all categories of all types 1482 * * hidden: only the hidden categories 1483 * * normal: all categories, except hidden categories 1484 * @return string[] 1485 */ 1486 public function getCategories( $type = 'all' ) { 1487 if ( $type === 'all' ) { 1488 $allCategories = []; 1489 foreach ( $this->mCategories as $categories ) { 1490 $allCategories = array_merge( $allCategories, $categories ); 1491 } 1492 return $allCategories; 1493 } 1494 if ( !isset( $this->mCategories[$type] ) ) { 1495 throw new InvalidArgumentException( 'Invalid category type given: ' . $type ); 1496 } 1497 return $this->mCategories[$type]; 1498 } 1499 1500 /** 1501 * Add an array of indicators, with their identifiers as array 1502 * keys and HTML contents as values. 1503 * 1504 * In case of duplicate keys, existing values are overwritten. 1505 * 1506 * @param string[] $indicators 1507 * @since 1.25 1508 */ 1509 public function setIndicators( array $indicators ) { 1510 $this->mIndicators = $indicators + $this->mIndicators; 1511 // Keep ordered by key 1512 ksort( $this->mIndicators ); 1513 } 1514 1515 /** 1516 * Get the indicators associated with this page. 1517 * 1518 * The array will be internally ordered by item keys. 1519 * 1520 * @return string[] Keys: identifiers, values: HTML contents 1521 * @since 1.25 1522 */ 1523 public function getIndicators() { 1524 return $this->mIndicators; 1525 } 1526 1527 /** 1528 * Adds help link with an icon via page indicators. 1529 * Link target can be overridden by a local message containing a wikilink: 1530 * the message key is: lowercase action or special page name + '-helppage'. 1531 * @param string $to Target MediaWiki.org page title or encoded URL. 1532 * @param bool $overrideBaseUrl Whether $url is a full URL, to avoid MW.o. 1533 * @since 1.25 1534 */ 1535 public function addHelpLink( $to, $overrideBaseUrl = false ) { 1536 $this->addModuleStyles( 'mediawiki.helplink' ); 1537 $text = $this->msg( 'helppage-top-gethelp' )->escaped(); 1538 1539 if ( $overrideBaseUrl ) { 1540 $helpUrl = $to; 1541 } else { 1542 $toUrlencoded = wfUrlencode( str_replace( ' ', '_', $to ) ); 1543 $helpUrl = "https://www.mediawiki.org/wiki/Special:MyLanguage/$toUrlencoded"; 1544 } 1545 1546 $link = Html::rawElement( 1547 'a', 1548 [ 1549 'href' => $helpUrl, 1550 'target' => '_blank', 1551 'class' => 'mw-helplink', 1552 ], 1553 $text 1554 ); 1555 1556 $this->setIndicators( [ 'mw-helplink' => $link ] ); 1557 } 1558 1559 /** 1560 * Do not allow scripts which can be modified by wiki users to load on this page; 1561 * only allow scripts bundled with, or generated by, the software. 1562 * Site-wide styles are controlled by a config setting, since they can be 1563 * used to create a custom skin/theme, but not user-specific ones. 1564 * 1565 * @todo this should be given a more accurate name 1566 */ 1567 public function disallowUserJs() { 1568 $this->reduceAllowedModules( 1569 ResourceLoaderModule::TYPE_SCRIPTS, 1570 ResourceLoaderModule::ORIGIN_CORE_INDIVIDUAL 1571 ); 1572 1573 // Site-wide styles are controlled by a config setting, see T73621 1574 // for background on why. User styles are never allowed. 1575 if ( $this->getConfig()->get( 'AllowSiteCSSOnRestrictedPages' ) ) { 1576 $styleOrigin = ResourceLoaderModule::ORIGIN_USER_SITEWIDE; 1577 } else { 1578 $styleOrigin = ResourceLoaderModule::ORIGIN_CORE_INDIVIDUAL; 1579 } 1580 $this->reduceAllowedModules( 1581 ResourceLoaderModule::TYPE_STYLES, 1582 $styleOrigin 1583 ); 1584 } 1585 1586 /** 1587 * Show what level of JavaScript / CSS untrustworthiness is allowed on this page 1588 * @see ResourceLoaderModule::$origin 1589 * @param string $type ResourceLoaderModule TYPE_ constant 1590 * @return int ResourceLoaderModule ORIGIN_ class constant 1591 */ 1592 public function getAllowedModules( $type ) { 1593 if ( $type == ResourceLoaderModule::TYPE_COMBINED ) { 1594 return min( array_values( $this->mAllowedModules ) ); 1595 } else { 1596 return $this->mAllowedModules[$type] ?? ResourceLoaderModule::ORIGIN_ALL; 1597 } 1598 } 1599 1600 /** 1601 * Limit the highest level of CSS/JS untrustworthiness allowed. 1602 * 1603 * If passed the same or a higher level than the current level of untrustworthiness set, the 1604 * level will remain unchanged. 1605 * 1606 * @param string $type 1607 * @param int $level ResourceLoaderModule class constant 1608 */ 1609 public function reduceAllowedModules( $type, $level ) { 1610 $this->mAllowedModules[$type] = min( $this->getAllowedModules( $type ), $level ); 1611 } 1612 1613 /** 1614 * Prepend $text to the body HTML 1615 * 1616 * @param string $text HTML 1617 */ 1618 public function prependHTML( $text ) { 1619 $this->mBodytext = $text . $this->mBodytext; 1620 } 1621 1622 /** 1623 * Append $text to the body HTML 1624 * 1625 * @param string $text HTML 1626 */ 1627 public function addHTML( $text ) { 1628 $this->mBodytext .= $text; 1629 } 1630 1631 /** 1632 * Shortcut for adding an Html::element via addHTML. 1633 * 1634 * @since 1.19 1635 * 1636 * @param string $element 1637 * @param array $attribs 1638 * @param string $contents 1639 */ 1640 public function addElement( $element, array $attribs = [], $contents = '' ) { 1641 $this->addHTML( Html::element( $element, $attribs, $contents ) ); 1642 } 1643 1644 /** 1645 * Clear the body HTML 1646 */ 1647 public function clearHTML() { 1648 $this->mBodytext = ''; 1649 } 1650 1651 /** 1652 * Get the body HTML 1653 * 1654 * @return string HTML 1655 */ 1656 public function getHTML() { 1657 return $this->mBodytext; 1658 } 1659 1660 /** 1661 * Get/set the ParserOptions object to use for wikitext parsing 1662 * 1663 * @return ParserOptions 1664 * @suppress PhanUndeclaredProperty For isBogus 1665 */ 1666 public function parserOptions() { 1667 if ( !$this->mParserOptions ) { 1668 if ( !$this->getUser()->isSafeToLoad() ) { 1669 // Context user isn't unstubbable yet, so don't try to get a 1670 // ParserOptions for it. And don't cache this ParserOptions 1671 // either. 1672 $po = ParserOptions::newFromAnon(); 1673 $po->setAllowUnsafeRawHtml( false ); 1674 $po->isBogus = true; 1675 return $po; 1676 } 1677 1678 $this->mParserOptions = ParserOptions::newFromContext( $this->getContext() ); 1679 $this->mParserOptions->setAllowUnsafeRawHtml( false ); 1680 } 1681 1682 return $this->mParserOptions; 1683 } 1684 1685 /** 1686 * Set the revision ID which will be seen by the wiki text parser 1687 * for things such as embedded {{REVISIONID}} variable use. 1688 * 1689 * @param int|null $revid A positive integer, or null 1690 * @return mixed Previous value 1691 */ 1692 public function setRevisionId( $revid ) { 1693 $val = $revid === null ? null : intval( $revid ); 1694 return wfSetVar( $this->mRevisionId, $val, true ); 1695 } 1696 1697 /** 1698 * Get the displayed revision ID 1699 * 1700 * @return int|null 1701 */ 1702 public function getRevisionId() { 1703 return $this->mRevisionId; 1704 } 1705 1706 /** 1707 * Whether the revision displayed is the latest revision of the page 1708 * 1709 * @since 1.34 1710 * @return bool 1711 */ 1712 public function isRevisionCurrent() { 1713 return $this->mRevisionId == 0 || $this->mRevisionId == $this->getTitle()->getLatestRevID(); 1714 } 1715 1716 /** 1717 * Set the timestamp of the revision which will be displayed. This is used 1718 * to avoid a extra DB call in Skin::lastModified(). 1719 * 1720 * @param string|null $timestamp 1721 * @return mixed Previous value 1722 */ 1723 public function setRevisionTimestamp( $timestamp ) { 1724 return wfSetVar( $this->mRevisionTimestamp, $timestamp, true ); 1725 } 1726 1727 /** 1728 * Get the timestamp of displayed revision. 1729 * This will be null if not filled by setRevisionTimestamp(). 1730 * 1731 * @return string|null 1732 */ 1733 public function getRevisionTimestamp() { 1734 return $this->mRevisionTimestamp; 1735 } 1736 1737 /** 1738 * Set the displayed file version 1739 * 1740 * @param File|null $file 1741 * @return mixed Previous value 1742 */ 1743 public function setFileVersion( $file ) { 1744 $val = null; 1745 if ( $file instanceof File && $file->exists() ) { 1746 $val = [ 'time' => $file->getTimestamp(), 'sha1' => $file->getSha1() ]; 1747 } 1748 return wfSetVar( $this->mFileVersion, $val, true ); 1749 } 1750 1751 /** 1752 * Get the displayed file version 1753 * 1754 * @return array|null ('time' => MW timestamp, 'sha1' => sha1) 1755 */ 1756 public function getFileVersion() { 1757 return $this->mFileVersion; 1758 } 1759 1760 /** 1761 * Get the templates used on this page 1762 * 1763 * @return array (namespace => dbKey => revId) 1764 * @since 1.18 1765 */ 1766 public function getTemplateIds() { 1767 return $this->mTemplateIds; 1768 } 1769 1770 /** 1771 * Get the files used on this page 1772 * 1773 * @return array [ dbKey => [ 'time' => MW timestamp or null, 'sha1' => sha1 or '' ] ] 1774 * @since 1.18 1775 */ 1776 public function getFileSearchOptions() { 1777 return $this->mImageTimeKeys; 1778 } 1779 1780 /** 1781 * Convert wikitext *in the user interface language* to HTML and 1782 * add it to the buffer. The result will not be 1783 * language-converted, as user interface messages are already 1784 * localized into a specific variant. Assumes that the current 1785 * page title will be used if optional $title is not 1786 * provided. Output will be tidy. 1787 * 1788 * @param string $text Wikitext in the user interface language 1789 * @param bool $linestart Is this the start of a line? (Defaults to true) 1790 * @param PageReference|null $title Optional title to use; default of `null` 1791 * means use current page title. 1792 * @throws MWException if $title is not provided and OutputPage::getTitle() 1793 * is null 1794 * @since 1.32 1795 */ 1796 public function addWikiTextAsInterface( 1797 $text, $linestart = true, PageReference $title = null 1798 ) { 1799 if ( $title === null ) { 1800 $title = $this->getTitle(); 1801 } 1802 if ( !$title ) { 1803 throw new MWException( 'Title is null' ); 1804 } 1805 $this->addWikiTextTitleInternal( $text, $title, $linestart, /*interface*/true ); 1806 } 1807 1808 /** 1809 * Convert wikitext *in the user interface language* to HTML and 1810 * add it to the buffer with a `<div class="$wrapperClass">` 1811 * wrapper. The result will not be language-converted, as user 1812 * interface messages as already localized into a specific 1813 * variant. The $text will be parsed in start-of-line context. 1814 * Output will be tidy. 1815 * 1816 * @param string $wrapperClass The class attribute value for the <div> 1817 * wrapper in the output HTML 1818 * @param string $text Wikitext in the user interface language 1819 * @since 1.32 1820 */ 1821 public function wrapWikiTextAsInterface( 1822 $wrapperClass, $text 1823 ) { 1824 $this->addWikiTextTitleInternal( 1825 $text, $this->getTitle(), 1826 /*linestart*/true, /*interface*/true, 1827 $wrapperClass 1828 ); 1829 } 1830 1831 /** 1832 * Convert wikitext *in the page content language* to HTML and add 1833 * it to the buffer. The result with be language-converted to the 1834 * user's preferred variant. Assumes that the current page title 1835 * will be used if optional $title is not provided. Output will be 1836 * tidy. 1837 * 1838 * @param string $text Wikitext in the page content language 1839 * @param bool $linestart Is this the start of a line? (Defaults to true) 1840 * @param PageReference|null $title Optional title to use; default of `null` 1841 * means use current page title. 1842 * @throws MWException if $title is not provided and OutputPage::getTitle() 1843 * is null 1844 * @since 1.32 1845 */ 1846 public function addWikiTextAsContent( 1847 $text, $linestart = true, PageReference $title = null 1848 ) { 1849 if ( $title === null ) { 1850 $title = $this->getTitle(); 1851 } 1852 if ( !$title ) { 1853 throw new MWException( 'Title is null' ); 1854 } 1855 $this->addWikiTextTitleInternal( $text, $title, $linestart, /*interface*/false ); 1856 } 1857 1858 /** 1859 * Add wikitext with a custom Title object. 1860 * Output is unwrapped. 1861 * 1862 * @param string $text Wikitext 1863 * @param PageReference $title 1864 * @param bool $linestart Is this the start of a line?@param 1865 * @param bool $interface Whether it is an interface message 1866 * (for example disables conversion) 1867 * @param string|null $wrapperClass if not empty, wraps the output in 1868 * a `<div class="$wrapperClass">` 1869 */ 1870 private function addWikiTextTitleInternal( 1871 $text, PageReference $title, $linestart, $interface, $wrapperClass = null 1872 ) { 1873 $parserOutput = $this->parseInternal( 1874 $text, $title, $linestart, $interface 1875 ); 1876 1877 $this->addParserOutput( $parserOutput, [ 1878 'enableSectionEditLinks' => false, 1879 'wrapperDivClass' => $wrapperClass ?? '', 1880 ] ); 1881 } 1882 1883 /** 1884 * Add all metadata associated with a ParserOutput object, but without the actual HTML. This 1885 * includes categories, language links, ResourceLoader modules, effects of certain magic words, 1886 * and so on. 1887 * 1888 * @since 1.24 1889 * @param ParserOutput $parserOutput 1890 */ 1891 public function addParserOutputMetadata( ParserOutput $parserOutput ) { 1892 $this->mLanguageLinks = 1893 array_merge( $this->mLanguageLinks, $parserOutput->getLanguageLinks() ); 1894 $this->addCategoryLinks( $parserOutput->getCategories() ); 1895 $this->setIndicators( $parserOutput->getIndicators() ); 1896 $this->mNewSectionLink = $parserOutput->getNewSection(); 1897 $this->mHideNewSectionLink = $parserOutput->getHideNewSection(); 1898 1899 if ( !$parserOutput->isCacheable() ) { 1900 $this->enableClientCache( false ); 1901 } 1902 $this->mNoGallery = $parserOutput->getNoGallery(); 1903 $this->mHeadItems = array_merge( $this->mHeadItems, $parserOutput->getHeadItems() ); 1904 $this->addModules( $parserOutput->getModules() ); 1905 $this->addModuleStyles( $parserOutput->getModuleStyles() ); 1906 $this->addJsConfigVars( $parserOutput->getJsConfigVars() ); 1907 $this->mPreventClickjacking = $this->mPreventClickjacking 1908 || $parserOutput->preventClickjacking(); 1909 $scriptSrcs = $parserOutput->getExtraCSPScriptSrcs(); 1910 foreach ( $scriptSrcs as $src ) { 1911 $this->getCSP()->addScriptSrc( $src ); 1912 } 1913 $defaultSrcs = $parserOutput->getExtraCSPDefaultSrcs(); 1914 foreach ( $defaultSrcs as $src ) { 1915 $this->getCSP()->addDefaultSrc( $src ); 1916 } 1917 $styleSrcs = $parserOutput->getExtraCSPStyleSrcs(); 1918 foreach ( $styleSrcs as $src ) { 1919 $this->getCSP()->addStyleSrc( $src ); 1920 } 1921 1922 // If $wgImagePreconnect is true, and if the output contains 1923 // images, give the user-agent a hint about foreign repos from 1924 // which those images may be served. See T123582. 1925 // 1926 // TODO: We don't have an easy way to know from which remote(s) 1927 // the image(s) will be served. For now, we only hint the first 1928 // valid one. 1929 if ( $this->getConfig()->get( 'ImagePreconnect' ) && count( $parserOutput->getImages() ) ) { 1930 $preconnect = []; 1931 $repoGroup = MediaWikiServices::getInstance()->getRepoGroup(); 1932 $repoGroup->forEachForeignRepo( static function ( $repo ) use ( &$preconnect ) { 1933 $preconnect[] = wfParseUrl( $repo->getZoneUrl( 'thumb' ) )['host']; 1934 } ); 1935 $preconnect[] = wfParseUrl( $repoGroup->getLocalRepo()->getZoneUrl( 'thumb' ) )['host']; 1936 foreach ( $preconnect as $host ) { 1937 if ( $host ) { 1938 $this->addLink( [ 'rel' => 'preconnect', 'href' => '//' . $host ] ); 1939 break; 1940 } 1941 } 1942 } 1943 1944 // Template versioning... 1945 foreach ( (array)$parserOutput->getTemplateIds() as $ns => $dbks ) { 1946 if ( isset( $this->mTemplateIds[$ns] ) ) { 1947 $this->mTemplateIds[$ns] = $dbks + $this->mTemplateIds[$ns]; 1948 } else { 1949 $this->mTemplateIds[$ns] = $dbks; 1950 } 1951 } 1952 // File versioning... 1953 foreach ( (array)$parserOutput->getFileSearchOptions() as $dbk => $data ) { 1954 $this->mImageTimeKeys[$dbk] = $data; 1955 } 1956 1957 // Hooks registered in the object 1958 $parserOutputHooks = $this->getConfig()->get( 'ParserOutputHooks' ); 1959 foreach ( $parserOutput->getOutputHooks() as $hookInfo ) { 1960 list( $hookName, $data ) = $hookInfo; 1961 if ( isset( $parserOutputHooks[$hookName] ) ) { 1962 $parserOutputHooks[$hookName]( $this, $parserOutput, $data ); 1963 } 1964 } 1965 1966 // Enable OOUI if requested via ParserOutput 1967 if ( $parserOutput->getEnableOOUI() ) { 1968 $this->enableOOUI(); 1969 } 1970 1971 // Include parser limit report 1972 if ( !$this->limitReportJSData ) { 1973 $this->limitReportJSData = $parserOutput->getLimitReportJSData(); 1974 } 1975 1976 // Link flags are ignored for now, but may in the future be 1977 // used to mark individual language links. 1978 $linkFlags = []; 1979 $this->getHookRunner()->onLanguageLinks( $this->getTitle(), $this->mLanguageLinks, $linkFlags ); 1980 $this->getHookRunner()->onOutputPageParserOutput( $this, $parserOutput ); 1981 1982 // This check must be after 'OutputPageParserOutput' runs in addParserOutputMetadata 1983 // so that extensions may modify ParserOutput to toggle TOC. 1984 // This cannot be moved to addParserOutputText because that is not 1985 // called by EditPage for Preview. 1986 if ( $parserOutput->getTOCHTML() ) { 1987 $this->mEnableTOC = true; 1988 } 1989 } 1990 1991 /** 1992 * Add the HTML and enhancements for it (like ResourceLoader modules) associated with a 1993 * ParserOutput object, without any other metadata. 1994 * 1995 * @since 1.24 1996 * @param ParserOutput $parserOutput 1997 * @param array $poOptions Options to ParserOutput::getText() 1998 */ 1999 public function addParserOutputContent( ParserOutput $parserOutput, $poOptions = [] ) { 2000 $this->addParserOutputText( $parserOutput, $poOptions ); 2001 2002 $this->addModules( $parserOutput->getModules() ); 2003 $this->addModuleStyles( $parserOutput->getModuleStyles() ); 2004 2005 $this->addJsConfigVars( $parserOutput->getJsConfigVars() ); 2006 } 2007 2008 /** 2009 * Add the HTML associated with a ParserOutput object, without any metadata. 2010 * 2011 * @since 1.24 2012 * @param ParserOutput $parserOutput 2013 * @param array $poOptions Options to ParserOutput::getText() 2014 */ 2015 public function addParserOutputText( ParserOutput $parserOutput, $poOptions = [] ) { 2016 $text = $parserOutput->getText( $poOptions ); 2017 $this->getHookRunner()->onOutputPageBeforeHTML( $this, $text ); 2018 $this->addHTML( $text ); 2019 } 2020 2021 /** 2022 * Add everything from a ParserOutput object. 2023 * 2024 * @param ParserOutput $parserOutput 2025 * @param array $poOptions Options to ParserOutput::getText() 2026 */ 2027 public function addParserOutput( ParserOutput $parserOutput, $poOptions = [] ) { 2028 $this->addParserOutputMetadata( $parserOutput ); 2029 $this->addParserOutputText( $parserOutput, $poOptions ); 2030 } 2031 2032 /** 2033 * Add the output of a QuickTemplate to the output buffer 2034 * 2035 * @param QuickTemplate &$template 2036 */ 2037 public function addTemplate( &$template ) { 2038 $this->addHTML( $template->getHTML() ); 2039 } 2040 2041 /** 2042 * Parse wikitext *in the page content language* and return the HTML. 2043 * The result will be language-converted to the user's preferred variant. 2044 * Output will be tidy. 2045 * 2046 * @param string $text Wikitext in the page content language 2047 * @param bool $linestart Is this the start of a line? (Defaults to true) 2048 * @throws MWException 2049 * @return string HTML 2050 * @since 1.32 2051 */ 2052 public function parseAsContent( $text, $linestart = true ) { 2053 return $this->parseInternal( 2054 $text, $this->getTitle(), $linestart, /*interface*/false 2055 )->getText( [ 2056 'enableSectionEditLinks' => false, 2057 'wrapperDivClass' => '' 2058 ] ); 2059 } 2060 2061 /** 2062 * Parse wikitext *in the user interface language* and return the HTML. 2063 * The result will not be language-converted, as user interface messages 2064 * are already localized into a specific variant. 2065 * Output will be tidy. 2066 * 2067 * @param string $text Wikitext in the user interface language 2068 * @param bool $linestart Is this the start of a line? (Defaults to true) 2069 * @throws MWException 2070 * @return string HTML 2071 * @since 1.32 2072 */ 2073 public function parseAsInterface( $text, $linestart = true ) { 2074 return $this->parseInternal( 2075 $text, $this->getTitle(), $linestart, /*interface*/true 2076 )->getText( [ 2077 'enableSectionEditLinks' => false, 2078 'wrapperDivClass' => '' 2079 ] ); 2080 } 2081 2082 /** 2083 * Parse wikitext *in the user interface language*, strip 2084 * paragraph wrapper, and return the HTML. 2085 * The result will not be language-converted, as user interface messages 2086 * are already localized into a specific variant. 2087 * Output will be tidy. Outer paragraph wrapper will only be stripped 2088 * if the result is a single paragraph. 2089 * 2090 * @param string $text Wikitext in the user interface language 2091 * @param bool $linestart Is this the start of a line? (Defaults to true) 2092 * @throws MWException 2093 * @return string HTML 2094 * @since 1.32 2095 */ 2096 public function parseInlineAsInterface( $text, $linestart = true ) { 2097 return Parser::stripOuterParagraph( 2098 $this->parseAsInterface( $text, $linestart ) 2099 ); 2100 } 2101 2102 /** 2103 * Parse wikitext and return the HTML (internal implementation helper) 2104 * 2105 * @param string $text 2106 * @param PageReference $title The title to use 2107 * @param bool $linestart Is this the start of a line? 2108 * @param bool $interface Use interface language (instead of content language) while parsing 2109 * language sensitive magic words like GRAMMAR and PLURAL. This also disables 2110 * LanguageConverter. 2111 * @throws MWException 2112 * @return ParserOutput 2113 */ 2114 private function parseInternal( $text, $title, $linestart, $interface ) { 2115 if ( $title === null ) { 2116 throw new MWException( 'Empty $mTitle in ' . __METHOD__ ); 2117 } 2118 2119 $popts = $this->parserOptions(); 2120 2121 $oldInterface = $popts->setInterfaceMessage( (bool)$interface ); 2122 2123 $parserOutput = MediaWikiServices::getInstance()->getParser()->getFreshParser()->parse( 2124 $text, $title, $popts, 2125 $linestart, true, $this->mRevisionId 2126 ); 2127 2128 $popts->setInterfaceMessage( $oldInterface ); 2129 2130 return $parserOutput; 2131 } 2132 2133 /** 2134 * Set the value of the "s-maxage" part of the "Cache-control" HTTP header 2135 * 2136 * @param int $maxage Maximum cache time on the CDN, in seconds. 2137 */ 2138 public function setCdnMaxage( $maxage ) { 2139 $this->mCdnMaxage = min( $maxage, $this->mCdnMaxageLimit ); 2140 } 2141 2142 /** 2143 * Set the value of the "s-maxage" part of the "Cache-control" HTTP header to $maxage if that is 2144 * lower than the current s-maxage. Either way, $maxage is now an upper limit on s-maxage, so 2145 * that future calls to setCdnMaxage() will no longer be able to raise the s-maxage above 2146 * $maxage. 2147 * 2148 * @param int $maxage Maximum cache time on the CDN, in seconds 2149 * @since 1.27 2150 */ 2151 public function lowerCdnMaxage( $maxage ) { 2152 $this->mCdnMaxageLimit = min( $maxage, $this->mCdnMaxageLimit ); 2153 $this->setCdnMaxage( $this->mCdnMaxage ); 2154 } 2155 2156 /** 2157 * Get TTL in [$minTTL,$maxTTL] and pass it to lowerCdnMaxage() 2158 * 2159 * This sets and returns $minTTL if $mtime is false or null. Otherwise, 2160 * the TTL is higher the older the $mtime timestamp is. Essentially, the 2161 * TTL is 90% of the age of the object, subject to the min and max. 2162 * 2163 * @param string|int|float|bool|null $mtime Last-Modified timestamp 2164 * @param int $minTTL Minimum TTL in seconds [default: 1 minute] 2165 * @param int $maxTTL Maximum TTL in seconds [default: $wgCdnMaxAge] 2166 * @since 1.28 2167 */ 2168 public function adaptCdnTTL( $mtime, $minTTL = 0, $maxTTL = 0 ) { 2169 $minTTL = $minTTL ?: IExpiringStore::TTL_MINUTE; 2170 $maxTTL = $maxTTL ?: $this->getConfig()->get( 'CdnMaxAge' ); 2171 2172 if ( $mtime === null || $mtime === false ) { 2173 return; // entity does not exist 2174 } 2175 2176 $age = MWTimestamp::time() - (int)wfTimestamp( TS_UNIX, $mtime ); 2177 $adaptiveTTL = max( 0.9 * $age, $minTTL ); 2178 $adaptiveTTL = min( $adaptiveTTL, $maxTTL ); 2179 2180 $this->lowerCdnMaxage( (int)$adaptiveTTL ); 2181 } 2182 2183 /** 2184 * Use enableClientCache(false) to force it to send nocache headers 2185 * 2186 * @param bool|null $state New value, or null to not set the value 2187 * 2188 * @return bool Old value 2189 */ 2190 public function enableClientCache( $state ) { 2191 return wfSetVar( $this->mEnableClientCache, $state ); 2192 } 2193 2194 /** 2195 * Whether the output might become publicly cached. 2196 * 2197 * @since 1.34 2198 * @return bool 2199 */ 2200 public function couldBePublicCached() { 2201 if ( !$this->cacheIsFinal ) { 2202 // - The entry point handles its own caching and/or doesn't use OutputPage. 2203 // (such as load.php, AjaxDispatcher, or MediaWiki\Rest\EntryPoint). 2204 // 2205 // - Or, we haven't finished processing the main part of the request yet 2206 // (e.g. Action::show, SpecialPage::execute), and the state may still 2207 // change via enableClientCache(). 2208 return true; 2209 } 2210 // e.g. various error-type pages disable all client caching 2211 return $this->mEnableClientCache; 2212 } 2213 2214 /** 2215 * Set the expectation that cache control will not change after this point. 2216 * 2217 * This should be called after the main processing logic has completed 2218 * (e.g. Action::show or SpecialPage::execute), but may be called 2219 * before Skin output has started (OutputPage::output). 2220 * 2221 * @since 1.34 2222 */ 2223 public function considerCacheSettingsFinal() { 2224 $this->cacheIsFinal = true; 2225 } 2226 2227 /** 2228 * Get the list of cookie names that will influence the cache 2229 * 2230 * @return array 2231 */ 2232 public function getCacheVaryCookies() { 2233 if ( self::$cacheVaryCookies === null ) { 2234 $config = $this->getConfig(); 2235 self::$cacheVaryCookies = array_values( array_unique( array_merge( 2236 SessionManager::singleton()->getVaryCookies(), 2237 [ 2238 'forceHTTPS', 2239 ], 2240 $config->get( 'CacheVaryCookies' ) 2241 ) ) ); 2242 $this->getHookRunner()->onGetCacheVaryCookies( $this, self::$cacheVaryCookies ); 2243 } 2244 return self::$cacheVaryCookies; 2245 } 2246 2247 /** 2248 * Check if the request has a cache-varying cookie header 2249 * If it does, it's very important that we don't allow public caching 2250 * 2251 * @return bool 2252 */ 2253 public function haveCacheVaryCookies() { 2254 $request = $this->getRequest(); 2255 foreach ( $this->getCacheVaryCookies() as $cookieName ) { 2256 if ( $request->getCookie( $cookieName, '', '' ) !== '' ) { 2257 wfDebug( __METHOD__ . ": found $cookieName" ); 2258 return true; 2259 } 2260 } 2261 wfDebug( __METHOD__ . ": no cache-varying cookies found" ); 2262 return false; 2263 } 2264 2265 /** 2266 * Add an HTTP header that will influence on the cache 2267 * 2268 * @param string $header Header name 2269 * @param string[]|null $option Deprecated; formerly options for the 2270 * Key header, deprecated in 1.32 and removed in 1.34. See 2271 * https://datatracker.ietf.org/doc/draft-fielding-http-key/ 2272 * for the list of formerly-valid options. 2273 */ 2274 public function addVaryHeader( $header, array $option = null ) { 2275 if ( $option !== null && count( $option ) > 0 ) { 2276 wfDeprecatedMsg( 2277 'The $option parameter to addVaryHeader is ignored since MediaWiki 1.34', 2278 '1.34' ); 2279 } 2280 if ( !array_key_exists( $header, $this->mVaryHeader ) ) { 2281 $this->mVaryHeader[$header] = null; 2282 } 2283 } 2284 2285 /** 2286 * Return a Vary: header on which to vary caches. Based on the keys of $mVaryHeader, 2287 * such as Accept-Encoding or Cookie 2288 * 2289 * @return string 2290 */ 2291 public function getVaryHeader() { 2292 // If we vary on cookies, let's make sure it's always included here too. 2293 if ( $this->getCacheVaryCookies() ) { 2294 $this->addVaryHeader( 'Cookie' ); 2295 } 2296 2297 foreach ( SessionManager::singleton()->getVaryHeaders() as $header => $options ) { 2298 $this->addVaryHeader( $header, $options ); 2299 } 2300 return 'Vary: ' . implode( ', ', array_keys( $this->mVaryHeader ) ); 2301 } 2302 2303 /** 2304 * Add an HTTP Link: header 2305 * 2306 * @param string $header Header value 2307 */ 2308 public function addLinkHeader( $header ) { 2309 $this->mLinkHeader[] = $header; 2310 } 2311 2312 /** 2313 * Return a Link: header. Based on the values of $mLinkHeader. 2314 * 2315 * @return string|false 2316 */ 2317 public function getLinkHeader() { 2318 if ( !$this->mLinkHeader ) { 2319 return false; 2320 } 2321 2322 return 'Link: ' . implode( ',', $this->mLinkHeader ); 2323 } 2324 2325 /** 2326 * T23672: Add Accept-Language to Vary header if there's no 'variant' parameter in GET. 2327 * 2328 * For example: 2329 * /w/index.php?title=Main_page will vary based on Accept-Language; but 2330 * /w/index.php?title=Main_page&variant=zh-cn will not. 2331 */ 2332 private function addAcceptLanguage() { 2333 $title = $this->getTitle(); 2334 if ( !$title instanceof Title ) { 2335 return; 2336 } 2337 2338 $languageConverter = MediaWikiServices::getInstance()->getLanguageConverterFactory() 2339 ->getLanguageConverter( $title->getPageLanguage() ); 2340 if ( !$this->getRequest()->getCheck( 'variant' ) && $languageConverter->hasVariants() ) { 2341 $this->addVaryHeader( 'Accept-Language' ); 2342 } 2343 } 2344 2345 /** 2346 * Set a flag which will cause an X-Frame-Options header appropriate for 2347 * edit pages to be sent. The header value is controlled by 2348 * $wgEditPageFrameOptions. 2349 * 2350 * This is the default for special pages. If you display a CSRF-protected 2351 * form on an ordinary view page, then you need to call this function. 2352 * 2353 * @param bool $enable 2354 */ 2355 public function preventClickjacking( $enable = true ) { 2356 $this->mPreventClickjacking = $enable; 2357 } 2358 2359 /** 2360 * Turn off frame-breaking. Alias for $this->preventClickjacking(false). 2361 * This can be called from pages which do not contain any CSRF-protected 2362 * HTML form. 2363 */ 2364 public function allowClickjacking() { 2365 $this->mPreventClickjacking = false; 2366 } 2367 2368 /** 2369 * Get the prevent-clickjacking flag 2370 * 2371 * @since 1.24 2372 * @return bool 2373 */ 2374 public function getPreventClickjacking() { 2375 return $this->mPreventClickjacking; 2376 } 2377 2378 /** 2379 * Get the X-Frame-Options header value (without the name part), or false 2380 * if there isn't one. This is used by Skin to determine whether to enable 2381 * JavaScript frame-breaking, for clients that don't support X-Frame-Options. 2382 * 2383 * @return string|false 2384 */ 2385 public function getFrameOptions() { 2386 $config = $this->getConfig(); 2387 if ( $config->get( 'BreakFrames' ) ) { 2388 return 'DENY'; 2389 } elseif ( $this->mPreventClickjacking && $config->get( 'EditPageFrameOptions' ) ) { 2390 return $config->get( 'EditPageFrameOptions' ); 2391 } 2392 return false; 2393 } 2394 2395 /** 2396 * Get the Origin-Trial header values. This is used to enable Chrome Origin 2397 * Trials: https://github.com/GoogleChrome/OriginTrials 2398 * 2399 * @return array 2400 */ 2401 private function getOriginTrials() { 2402 $config = $this->getConfig(); 2403 2404 return $config->get( 'OriginTrials' ); 2405 } 2406 2407 private function getReportTo() { 2408 $config = $this->getConfig(); 2409 2410 $expiry = $config->get( 'ReportToExpiry' ); 2411 2412 if ( !$expiry ) { 2413 return false; 2414 } 2415 2416 $endpoints = $config->get( 'ReportToEndpoints' ); 2417 2418 if ( !$endpoints ) { 2419 return false; 2420 } 2421 2422 $output = [ 'max_age' => $expiry, 'endpoints' => [] ]; 2423 2424 foreach ( $endpoints as $endpoint ) { 2425 $output['endpoints'][] = [ 'url' => $endpoint ]; 2426 } 2427 2428 return json_encode( $output, JSON_UNESCAPED_SLASHES ); 2429 } 2430 2431 private function getFeaturePolicyReportOnly() { 2432 $config = $this->getConfig(); 2433 2434 $features = $config->get( 'FeaturePolicyReportOnly' ); 2435 return implode( ';', $features ); 2436 } 2437 2438 /** 2439 * Send cache control HTTP headers 2440 */ 2441 public function sendCacheControl() { 2442 $response = $this->getRequest()->response(); 2443 $config = $this->getConfig(); 2444 2445 $this->addVaryHeader( 'Cookie' ); 2446 $this->addAcceptLanguage(); 2447 2448 # don't serve compressed data to clients who can't handle it 2449 # maintain different caches for logged-in users and non-logged in ones 2450 $response->header( $this->getVaryHeader() ); 2451 2452 if ( $this->mEnableClientCache ) { 2453 if ( !$config->get( 'UseCdn' ) ) { 2454 $privateReason = 'config'; 2455 } elseif ( $response->hasCookies() ) { 2456 $privateReason = 'set-cookies'; 2457 // The client might use methods other than cookies to appear logged-in. 2458 // E.g. HTTP headers, or query parameter tokens, OAuth, etc. 2459 } elseif ( SessionManager::getGlobalSession()->isPersistent() ) { 2460 $privateReason = 'session'; 2461 } elseif ( $this->isPrintable() ) { 2462 $privateReason = 'printable'; 2463 } elseif ( $this->mCdnMaxage == 0 ) { 2464 $privateReason = 'no-maxage'; 2465 } elseif ( $this->haveCacheVaryCookies() ) { 2466 $privateReason = 'cache-vary-cookies'; 2467 } else { 2468 $privateReason = false; 2469 } 2470 2471 if ( $privateReason === false ) { 2472 # We'll purge the proxy cache for anons explicitly, but require end user agents 2473 # to revalidate against the proxy on each visit. 2474 # IMPORTANT! The CDN needs to replace the Cache-Control header with 2475 # Cache-Control: s-maxage=0, must-revalidate, max-age=0 2476 wfDebug( __METHOD__ . 2477 ": local proxy caching; {$this->mLastModified} **", 'private' ); 2478 # start with a shorter timeout for initial testing 2479 # header( "Cache-Control: s-maxage=2678400, must-revalidate, max-age=0" ); 2480 $response->header( "Cache-Control: " . 2481 "s-maxage={$this->mCdnMaxage}, must-revalidate, max-age=0" ); 2482 } else { 2483 # We do want clients to cache if they can, but they *must* check for updates 2484 # on revisiting the page, after the max-age period. 2485 wfDebug( __METHOD__ . ": private caching ($privateReason); {$this->mLastModified} **", 'private' ); 2486 2487 if ( $response->hasCookies() || SessionManager::getGlobalSession()->isPersistent() ) { 2488 $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' ); 2489 $response->header( "Cache-Control: private, must-revalidate, max-age=0" ); 2490 } else { 2491 $response->header( 2492 'Expires: ' . gmdate( 'D, d M Y H:i:s', time() + $config->get( 'LoggedOutMaxAge' ) ) . ' GMT' 2493 ); 2494 $response->header( 2495 "Cache-Control: private, must-revalidate, max-age={$config->get( 'LoggedOutMaxAge' )}" 2496 ); 2497 } 2498 } 2499 if ( $this->mLastModified ) { 2500 $response->header( "Last-Modified: {$this->mLastModified}" ); 2501 } 2502 } else { 2503 wfDebug( __METHOD__ . ": no caching **", 'private' ); 2504 2505 # In general, the absence of a last modified header should be enough to prevent 2506 # the client from using its cache. We send a few other things just to make sure. 2507 $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' ); 2508 $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' ); 2509 $response->header( 'Pragma: no-cache' ); 2510 } 2511 } 2512 2513 /** 2514 * Transfer styles and JavaScript modules from skin. 2515 * 2516 * @param Skin $sk to load modules for 2517 */ 2518 public function loadSkinModules( $sk ) { 2519 foreach ( $sk->getDefaultModules() as $group => $modules ) { 2520 if ( $group === 'styles' ) { 2521 foreach ( $modules as $key => $moduleMembers ) { 2522 $this->addModuleStyles( $moduleMembers ); 2523 } 2524 } else { 2525 $this->addModules( $modules ); 2526 } 2527 } 2528 } 2529 2530 /** 2531 * Finally, all the text has been munged and accumulated into 2532 * the object, let's actually output it: 2533 * 2534 * @param bool $return Set to true to get the result as a string rather than sending it 2535 * @return string|null 2536 * @throws Exception 2537 * @throws FatalError 2538 * @throws MWException 2539 */ 2540 public function output( $return = false ) { 2541 if ( $this->mDoNothing ) { 2542 return $return ? '' : null; 2543 } 2544 2545 $response = $this->getRequest()->response(); 2546 $config = $this->getConfig(); 2547 2548 if ( $this->mRedirect != '' ) { 2549 # Standards require redirect URLs to be absolute 2550 $this->mRedirect = wfExpandUrl( $this->mRedirect, PROTO_CURRENT ); 2551 2552 $redirect = $this->mRedirect; 2553 $code = $this->mRedirectCode; 2554 $content = ''; 2555 2556 if ( $this->getHookRunner()->onBeforePageRedirect( $this, $redirect, $code ) ) { 2557 if ( $code == '301' || $code == '303' ) { 2558 if ( !$config->get( 'DebugRedirects' ) ) { 2559 $response->statusHeader( $code ); 2560 } 2561 $this->mLastModified = wfTimestamp( TS_RFC2822 ); 2562 } 2563 if ( $config->get( 'VaryOnXFP' ) ) { 2564 $this->addVaryHeader( 'X-Forwarded-Proto' ); 2565 } 2566 $this->sendCacheControl(); 2567 2568 $response->header( "Content-Type: text/html; charset=utf-8" ); 2569 if ( $config->get( 'DebugRedirects' ) ) { 2570 $url = htmlspecialchars( $redirect ); 2571 $content = "<!DOCTYPE html>\n<html>\n<head>\n" 2572 . "<title>Redirect</title>\n</head>\n<body>\n" 2573 . "<p>Location: <a href=\"$url\">$url</a></p>\n" 2574 . "</body>\n</html>\n"; 2575 2576 if ( !$return ) { 2577 print $content; 2578 } 2579 2580 } else { 2581 $response->header( 'Location: ' . $redirect ); 2582 } 2583 } 2584 2585 return $return ? $content : null; 2586 } elseif ( $this->mStatusCode ) { 2587 $response->statusHeader( $this->mStatusCode ); 2588 } 2589 2590 # Buffer output; final headers may depend on later processing 2591 ob_start(); 2592 2593 $response->header( 'Content-type: ' . $config->get( 'MimeType' ) . '; charset=UTF-8' ); 2594 $response->header( 'Content-language: ' . 2595 MediaWikiServices::getInstance()->getContentLanguage()->getHtmlCode() ); 2596 2597 $linkHeader = $this->getLinkHeader(); 2598 if ( $linkHeader ) { 2599 $response->header( $linkHeader ); 2600 } 2601 2602 // Prevent framing, if requested 2603 $frameOptions = $this->getFrameOptions(); 2604 if ( $frameOptions ) { 2605 $response->header( "X-Frame-Options: $frameOptions" ); 2606 } 2607 2608 $originTrials = $this->getOriginTrials(); 2609 foreach ( $originTrials as $originTrial ) { 2610 $response->header( "Origin-Trial: $originTrial", false ); 2611 } 2612 2613 $reportTo = $this->getReportTo(); 2614 if ( $reportTo ) { 2615 $response->header( "Report-To: $reportTo" ); 2616 } 2617 2618 $featurePolicyReportOnly = $this->getFeaturePolicyReportOnly(); 2619 if ( $featurePolicyReportOnly ) { 2620 $response->header( "Feature-Policy-Report-Only: $featurePolicyReportOnly" ); 2621 } 2622 2623 if ( $this->mArticleBodyOnly ) { 2624 $this->CSP->sendHeaders(); 2625 echo $this->mBodytext; 2626 } else { 2627 // Enable safe mode if requested (T152169) 2628 if ( $this->getRequest()->getBool( 'safemode' ) ) { 2629 $this->disallowUserJs(); 2630 } 2631 2632 $sk = $this->getSkin(); 2633 $this->loadSkinModules( $sk ); 2634 2635 MWDebug::addModules( $this ); 2636 2637 // Hook that allows last minute changes to the output page, e.g. 2638 // adding of CSS or Javascript by extensions, adding CSP sources. 2639 $this->getHookRunner()->onBeforePageDisplay( $this, $sk ); 2640 2641 $this->CSP->sendHeaders(); 2642 2643 try { 2644 $sk->outputPage(); 2645 } catch ( Exception $e ) { 2646 ob_end_clean(); // bug T129657 2647 throw $e; 2648 } 2649 } 2650 2651 try { 2652 // This hook allows last minute changes to final overall output by modifying output buffer 2653 $this->getHookRunner()->onAfterFinalPageOutput( $this ); 2654 } catch ( Exception $e ) { 2655 ob_end_clean(); // bug T129657 2656 throw $e; 2657 } 2658 2659 $this->sendCacheControl(); 2660 2661 if ( $return ) { 2662 return ob_get_clean(); 2663 } else { 2664 ob_end_flush(); 2665 return null; 2666 } 2667 } 2668 2669 /** 2670 * Prepare this object to display an error page; disable caching and 2671 * indexing, clear the current text and redirect, set the page's title 2672 * and optionally an custom HTML title (content of the "<title>" tag). 2673 * 2674 * @param string|Message $pageTitle Will be passed directly to setPageTitle() 2675 * @param string|Message|false $htmlTitle Will be passed directly to setHTMLTitle(); 2676 * optional, if not passed the "<title>" attribute will be 2677 * based on $pageTitle 2678 */ 2679 public function prepareErrorPage( $pageTitle, $htmlTitle = false ) { 2680 $this->setPageTitle( $pageTitle ); 2681 if ( $htmlTitle !== false ) { 2682 $this->setHTMLTitle( $htmlTitle ); 2683 } 2684 $this->setRobotPolicy( 'noindex,nofollow' ); 2685 $this->setArticleRelated( false ); 2686 $this->enableClientCache( false ); 2687 $this->mRedirect = ''; 2688 $this->clearSubtitle(); 2689 $this->clearHTML(); 2690 } 2691 2692 /** 2693 * Output a standard error page 2694 * 2695 * showErrorPage( 'titlemsg', 'pagetextmsg' ); 2696 * showErrorPage( 'titlemsg', 'pagetextmsg', [ 'param1', 'param2' ] ); 2697 * showErrorPage( 'titlemsg', $messageObject ); 2698 * showErrorPage( $titleMessageObject, $messageObject ); 2699 * 2700 * @param string|Message $title Message key (string) for page title, or a Message object 2701 * @param string|Message $msg Message key (string) for page text, or a Message object 2702 * @param array $params Message parameters; ignored if $msg is a Message object 2703 */ 2704 public function showErrorPage( $title, $msg, $params = [] ) { 2705 if ( !$title instanceof Message ) { 2706 $title = $this->msg( $title ); 2707 } 2708 2709 $this->prepareErrorPage( $title ); 2710 2711 if ( $msg instanceof Message ) { 2712 if ( $params !== [] ) { 2713 trigger_error( 'Argument ignored: $params. The message parameters argument ' 2714 . 'is discarded when the $msg argument is a Message object instead of ' 2715 . 'a string.', E_USER_NOTICE ); 2716 } 2717 $this->addHTML( $msg->parseAsBlock() ); 2718 } else { 2719 $this->addWikiMsgArray( $msg, $params ); 2720 } 2721 2722 $this->returnToMain(); 2723 } 2724 2725 /** 2726 * Output a standard permission error page 2727 * 2728 * @param array $errors Error message keys or [key, param...] arrays 2729 * @param string|null $action Action that was denied or null if unknown 2730 */ 2731 public function showPermissionsErrorPage( array $errors, $action = null ) { 2732 $services = MediaWikiServices::getInstance(); 2733 $permissionManager = $services->getPermissionManager(); 2734 foreach ( $errors as $key => $error ) { 2735 $errors[$key] = (array)$error; 2736 } 2737 2738 // For some action (read, edit, create and upload), display a "login to do this action" 2739 // error if all of the following conditions are met: 2740 // 1. the user is not logged in 2741 // 2. the only error is insufficient permissions (i.e. no block or something else) 2742 // 3. the error can be avoided simply by logging in 2743 2744 if ( in_array( $action, [ 'read', 'edit', 'createpage', 'createtalk', 'upload' ] ) 2745 && $this->getUser()->isAnon() && count( $errors ) == 1 && isset( $errors[0][0] ) 2746 && ( $errors[0][0] == 'badaccess-groups' || $errors[0][0] == 'badaccess-group0' ) 2747 && ( $permissionManager->groupHasPermission( 'user', $action ) 2748 || $permissionManager->groupHasPermission( 'autoconfirmed', $action ) ) 2749 ) { 2750 $displayReturnto = null; 2751 2752 # Due to T34276, if a user does not have read permissions, 2753 # $this->getTitle() will just give Special:Badtitle, which is 2754 # not especially useful as a returnto parameter. Use the title 2755 # from the request instead, if there was one. 2756 $request = $this->getRequest(); 2757 $returnto = Title::newFromText( $request->getText( 'title' ) ); 2758 if ( $action == 'edit' ) { 2759 $msg = 'whitelistedittext'; 2760 $displayReturnto = $returnto; 2761 } elseif ( $action == 'createpage' || $action == 'createtalk' ) { 2762 $msg = 'nocreatetext'; 2763 } elseif ( $action == 'upload' ) { 2764 $msg = 'uploadnologintext'; 2765 } else { # Read 2766 $msg = 'loginreqpagetext'; 2767 $displayReturnto = Title::newMainPage(); 2768 } 2769 2770 $query = []; 2771 2772 if ( $returnto ) { 2773 $query['returnto'] = $returnto->getPrefixedText(); 2774 2775 if ( !$request->wasPosted() ) { 2776 $returntoquery = $request->getValues(); 2777 unset( $returntoquery['title'] ); 2778 unset( $returntoquery['returnto'] ); 2779 unset( $returntoquery['returntoquery'] ); 2780 $query['returntoquery'] = wfArrayToCgi( $returntoquery ); 2781 } 2782 } 2783 2784 $title = SpecialPage::getTitleFor( 'Userlogin' ); 2785 $linkRenderer = $services->getLinkRenderer(); 2786 $loginUrl = $title->getLinkURL( $query, false, PROTO_RELATIVE ); 2787 $loginLink = $linkRenderer->makeKnownLink( 2788 $title, 2789 $this->msg( 'loginreqlink' )->text(), 2790 [], 2791 $query 2792 ); 2793 2794 $this->prepareErrorPage( $this->msg( 'loginreqtitle' ) ); 2795 $this->addHTML( $this->msg( $msg )->rawParams( $loginLink )->params( $loginUrl )->parse() ); 2796 2797 # Don't return to a page the user can't read otherwise 2798 # we'll end up in a pointless loop 2799 if ( $displayReturnto && $this->getAuthority()->probablyCan( 'read', $displayReturnto ) ) { 2800 $this->returnToMain( null, $displayReturnto ); 2801 } 2802 } else { 2803 $this->prepareErrorPage( $this->msg( 'permissionserrors' ) ); 2804 $this->addWikiTextAsInterface( $this->formatPermissionsErrorMessage( $errors, $action ) ); 2805 } 2806 } 2807 2808 /** 2809 * Display an error page indicating that a given version of MediaWiki is 2810 * required to use it 2811 * 2812 * @param mixed $version The version of MediaWiki needed to use the page 2813 */ 2814 public function versionRequired( $version ) { 2815 $this->prepareErrorPage( $this->msg( 'versionrequired', $version ) ); 2816 2817 $this->addWikiMsg( 'versionrequiredtext', $version ); 2818 $this->returnToMain(); 2819 } 2820 2821 /** 2822 * Format permission $status obtained from Authority for display. 2823 * 2824 * @param PermissionStatus $status 2825 * @param string|null $action that was denied or null if unknown 2826 * @return string 2827 */ 2828 public function formatPermissionStatus( PermissionStatus $status, string $action = null ): string { 2829 if ( $status->isGood() ) { 2830 return ''; 2831 } 2832 return $this->formatPermissionsErrorMessage( $status->toLegacyErrorArray(), $action ); 2833 } 2834 2835 /** 2836 * Format a list of error messages 2837 * 2838 * @deprecated since 1.36. Use ::formatPermissionStatus instead 2839 * @param array $errors Array of arrays returned by PermissionManager::getPermissionErrors 2840 * @param string|null $action Action that was denied or null if unknown 2841 * @return string The wikitext error-messages, formatted into a list. 2842 */ 2843 public function formatPermissionsErrorMessage( array $errors, $action = null ) { 2844 if ( $action == null ) { 2845 $text = $this->msg( 'permissionserrorstext', count( $errors ) )->plain() . "\n\n"; 2846 } else { 2847 $action_desc = $this->msg( "action-$action" )->plain(); 2848 $text = $this->msg( 2849 'permissionserrorstext-withaction', 2850 count( $errors ), 2851 $action_desc 2852 )->plain() . "\n\n"; 2853 } 2854 2855 if ( count( $errors ) > 1 ) { 2856 $text .= '<ul class="permissions-errors">' . "\n"; 2857 2858 foreach ( $errors as $error ) { 2859 $text .= '<li>'; 2860 $text .= $this->msg( ...$error )->plain(); 2861 $text .= "</li>\n"; 2862 } 2863 $text .= '</ul>'; 2864 } else { 2865 $text .= "<div class=\"permissions-errors\">\n" . 2866 $this->msg( ...reset( $errors ) )->plain() . 2867 "\n</div>"; 2868 } 2869 2870 return $text; 2871 } 2872 2873 /** 2874 * Show a warning about replica DB lag 2875 * 2876 * If the lag is higher than $wgDatabaseReplicaLagCritical seconds, 2877 * then the warning is a bit more obvious. If the lag is 2878 * lower than $wgDatabaseReplicaLagWarning, then no warning is shown. 2879 * 2880 * @param int $lag Replica lag 2881 */ 2882 public function showLagWarning( $lag ) { 2883 $config = $this->getConfig(); 2884 if ( $lag >= $config->get( 'DatabaseReplicaLagWarning' ) ) { 2885 $lag = floor( $lag ); // floor to avoid nano seconds to display 2886 $message = $lag < $config->get( 'DatabaseReplicaLagCritical' ) 2887 ? 'lag-warn-normal' 2888 : 'lag-warn-high'; 2889 // For grep: mw-lag-warn-normal, mw-lag-warn-high 2890 $wrap = Html::rawElement( 'div', [ 'class' => "mw-{$message}" ], "\n$1\n" ); 2891 $this->wrapWikiMsg( "$wrap\n", [ $message, $this->getLanguage()->formatNum( $lag ) ] ); 2892 } 2893 } 2894 2895 /** 2896 * Output an error page 2897 * 2898 * @note FatalError exception class provides an alternative. 2899 * @param string $message Error to output. Must be escaped for HTML. 2900 */ 2901 public function showFatalError( $message ) { 2902 $this->prepareErrorPage( $this->msg( 'internalerror' ) ); 2903 2904 $this->addHTML( $message ); 2905 } 2906 2907 /** 2908 * Add a "return to" link pointing to a specified title 2909 * 2910 * @param LinkTarget $title Title to link 2911 * @param array $query Query string parameters 2912 * @param string|null $text Text of the link (input is not escaped) 2913 * @param array $options Options array to pass to Linker 2914 */ 2915 public function addReturnTo( $title, array $query = [], $text = null, $options = [] ) { 2916 $linkRenderer = MediaWikiServices::getInstance() 2917 ->getLinkRendererFactory()->createFromLegacyOptions( $options ); 2918 $link = $this->msg( 'returnto' )->rawParams( 2919 $linkRenderer->makeLink( $title, $text, [], $query ) )->escaped(); 2920 $this->addHTML( "<p id=\"mw-returnto\">{$link}</p>\n" ); 2921 } 2922 2923 /** 2924 * Add a "return to" link pointing to a specified title, 2925 * or the title indicated in the request, or else the main page 2926 * 2927 * @param mixed|null $unused 2928 * @param PageReference|LinkTarget|string|null $returnto Page to return to 2929 * @param string|null $returntoquery Query string for the return to link 2930 */ 2931 public function returnToMain( $unused = null, $returnto = null, $returntoquery = null ) { 2932 if ( $returnto == null ) { 2933 $returnto = $this->getRequest()->getText( 'returnto' ); 2934 } 2935 2936 if ( $returntoquery == null ) { 2937 $returntoquery = $this->getRequest()->getText( 'returntoquery' ); 2938 } 2939 2940 if ( $returnto === '' ) { 2941 $returnto = Title::newMainPage(); 2942 } 2943 2944 if ( is_object( $returnto ) ) { 2945 $linkTarget = TitleValue::castPageToLinkTarget( $returnto ); 2946 } else { 2947 $linkTarget = Title::newFromText( $returnto ); 2948 } 2949 2950 // We don't want people to return to external interwiki. That 2951 // might potentially be used as part of a phishing scheme 2952 if ( !is_object( $linkTarget ) || $linkTarget->isExternal() ) { 2953 $linkTarget = Title::newMainPage(); 2954 } 2955 2956 $this->addReturnTo( $linkTarget, wfCgiToArray( $returntoquery ) ); 2957 } 2958 2959 private function getRlClientContext() { 2960 if ( !$this->rlClientContext ) { 2961 $query = ResourceLoader::makeLoaderQuery( 2962 [], // modules; not relevant 2963 $this->getLanguage()->getCode(), 2964 $this->getSkin()->getSkinName(), 2965 $this->getUser()->isRegistered() ? $this->getUser()->getName() : null, 2966 null, // version; not relevant 2967 ResourceLoader::inDebugMode(), 2968 null, // only; not relevant 2969 $this->isPrintable(), 2970 $this->getRequest()->getBool( 'handheld' ) 2971 ); 2972 $this->rlClientContext = new ResourceLoaderContext( 2973 $this->getResourceLoader(), 2974 new FauxRequest( $query ) 2975 ); 2976 if ( $this->contentOverrideCallbacks ) { 2977 $this->rlClientContext = new DerivativeResourceLoaderContext( $this->rlClientContext ); 2978 $this->rlClientContext->setContentOverrideCallback( function ( $title ) { 2979 foreach ( $this->contentOverrideCallbacks as $callback ) { 2980 $content = $callback( $title ); 2981 if ( $content !== null ) { 2982 $text = ContentHandler::getContentText( $content ); 2983 if ( strpos( $text, '</script>' ) !== false ) { 2984 // Proactively replace this so that we can display a message 2985 // to the user, instead of letting it go to Html::inlineScript(), 2986 // where it would be considered a server-side issue. 2987 $content = new JavaScriptContent( 2988 Xml::encodeJsCall( 'mw.log.error', [ 2989 "Cannot preview $title due to script-closing tag." 2990 ] ) 2991 ); 2992 } 2993 return $content; 2994 } 2995 } 2996 return null; 2997 } ); 2998 } 2999 } 3000 return $this->rlClientContext; 3001 } 3002 3003 /** 3004 * Call this to freeze the module queue and JS config and create a formatter. 3005 * 3006 * Depending on the Skin, this may get lazy-initialised in either headElement() or 3007 * getBottomScripts(). See SkinTemplate::prepareQuickTemplate(). Calling this too early may 3008 * cause unexpected side-effects since disallowUserJs() may be called at any time to change 3009 * the module filters retroactively. Skins and extension hooks may also add modules until very 3010 * late in the request lifecycle. 3011 * 3012 * @return ResourceLoaderClientHtml 3013 */ 3014 public function getRlClient() { 3015 if ( !$this->rlClient ) { 3016 $context = $this->getRlClientContext(); 3017 $rl = $this->getResourceLoader(); 3018 $this->addModules( [ 3019 'user', 3020 'user.options', 3021 ] ); 3022 $this->addModuleStyles( [ 3023 'site.styles', 3024 'noscript', 3025 'user.styles', 3026 ] ); 3027 3028 // Prepare exempt modules for buildExemptModules() 3029 $exemptGroups = [ 'site' => [], 'noscript' => [], 'private' => [], 'user' => [] ]; 3030 $exemptStates = []; 3031 $moduleStyles = $this->getModuleStyles( /*filter*/ true ); 3032 3033 // Preload getTitleInfo for isKnownEmpty calls below and in ResourceLoaderClientHtml 3034 // Separate user-specific batch for improved cache-hit ratio. 3035 $userBatch = [ 'user.styles', 'user' ]; 3036 $siteBatch = array_diff( $moduleStyles, $userBatch ); 3037 $dbr = wfGetDB( DB_REPLICA ); 3038 ResourceLoaderWikiModule::preloadTitleInfo( $context, $dbr, $siteBatch ); 3039 ResourceLoaderWikiModule::preloadTitleInfo( $context, $dbr, $userBatch ); 3040 3041 // Filter out modules handled by buildExemptModules() 3042 $moduleStyles = array_filter( $moduleStyles, 3043 static function ( $name ) use ( $rl, $context, &$exemptGroups, &$exemptStates ) { 3044 $module = $rl->getModule( $name ); 3045 if ( $module ) { 3046 $group = $module->getGroup(); 3047 if ( isset( $exemptGroups[$group] ) ) { 3048 $exemptStates[$name] = 'ready'; 3049 if ( !$module->isKnownEmpty( $context ) ) { 3050 // E.g. Don't output empty <styles> 3051 $exemptGroups[$group][] = $name; 3052 } 3053 return false; 3054 } 3055 } 3056 return true; 3057 } 3058 ); 3059 $this->rlExemptStyleModules = $exemptGroups; 3060 3061 $rlClient = new ResourceLoaderClientHtml( $context, [ 3062 'target' => $this->getTarget(), 3063 'nonce' => $this->CSP->getNonce(), 3064 // When 'safemode', disallowUserJs(), or reduceAllowedModules() is used 3065 // to only restrict modules to ORIGIN_CORE (ie. disallow ORIGIN_USER), the list of 3066 // modules enqueud for loading on this page is filtered to just those. 3067 // However, to make sure we also apply the restriction to dynamic dependencies and 3068 // lazy-loaded modules at run-time on the client-side, pass 'safemode' down to the 3069 // StartupModule so that the client-side registry will not contain any restricted 3070 // modules either. (T152169, T185303) 3071 'safemode' => ( $this->getAllowedModules( ResourceLoaderModule::TYPE_COMBINED ) 3072 <= ResourceLoaderModule::ORIGIN_CORE_INDIVIDUAL 3073 ) ? '1' : null, 3074 ] ); 3075 $rlClient->setConfig( $this->getJSVars() ); 3076 $rlClient->setModules( $this->getModules( /*filter*/ true ) ); 3077 $rlClient->setModuleStyles( $moduleStyles ); 3078 $rlClient->setExemptStates( $exemptStates ); 3079 $this->rlClient = $rlClient; 3080 } 3081 return $this->rlClient; 3082 } 3083 3084 /** 3085 * @param Skin $sk The given Skin 3086 * @param bool $includeStyle Unused 3087 * @return string The doctype, opening "<html>", and head element. 3088 */ 3089 public function headElement( Skin $sk, $includeStyle = true ) { 3090 $config = $this->getConfig(); 3091 $userdir = $this->getLanguage()->getDir(); 3092 $services = MediaWikiServices::getInstance(); 3093 $sitedir = $services->getContentLanguage()->getDir(); 3094 3095 $pieces = []; 3096 $htmlAttribs = Sanitizer::mergeAttributes( Sanitizer::mergeAttributes( 3097 $this->getRlClient()->getDocumentAttributes(), 3098 $sk->getHtmlElementAttributes() 3099 ), [ 'class' => implode( ' ', $this->mAdditionalHtmlClasses ) ] ); 3100 $pieces[] = Html::htmlHeader( $htmlAttribs ); 3101 $pieces[] = Html::openElement( 'head' ); 3102 3103 if ( $this->getHTMLTitle() == '' ) { 3104 $this->setHTMLTitle( $this->msg( 'pagetitle', $this->getPageTitle() )->inContentLanguage() ); 3105 } 3106 3107 if ( !Html::isXmlMimeType( $config->get( 'MimeType' ) ) ) { 3108 // Add <meta charset="UTF-8"> 3109 // This should be before <title> since it defines the charset used by 3110 // text including the text inside <title>. 3111 // The spec recommends defining XHTML5's charset using the XML declaration 3112 // instead of meta. 3113 // Our XML declaration is output by Html::htmlHeader. 3114 // https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-http-equiv-content-type 3115 // https://html.spec.whatwg.org/multipage/semantics.html#charset 3116 $pieces[] = Html::element( 'meta', [ 'charset' => 'UTF-8' ] ); 3117 } 3118 3119 $pieces[] = Html::element( 'title', null, $this->getHTMLTitle() ); 3120 $pieces[] = $this->getRlClient()->getHeadHtml( $htmlAttribs['class'] ?? null ); 3121 $pieces[] = $this->buildExemptModules(); 3122 $pieces = array_merge( $pieces, array_values( $this->getHeadLinksArray() ) ); 3123 $pieces = array_merge( $pieces, array_values( $this->mHeadItems ) ); 3124 3125 $pieces[] = Html::closeElement( 'head' ); 3126 3127 $bodyClasses = $this->mAdditionalBodyClasses; 3128 $bodyClasses[] = 'mediawiki'; 3129 3130 # Classes for LTR/RTL directionality support 3131 $bodyClasses[] = $userdir; 3132 $bodyClasses[] = "sitedir-$sitedir"; 3133 3134 $underline = $services->getUserOptionsLookup()->getOption( $this->getUser(), 'underline' ); 3135 if ( $underline < 2 ) { 3136 // The following classes can be used here: 3137 // * mw-underline-always 3138 // * mw-underline-never 3139 $bodyClasses[] = 'mw-underline-' . ( $underline ? 'always' : 'never' ); 3140 } 3141 3142 // Parser feature migration class 3143 // The idea is that this will eventually be removed, after the wikitext 3144 // which requires it is cleaned up. 3145 $bodyClasses[] = 'mw-hide-empty-elt'; 3146 3147 $bodyClasses[] = $sk->getPageClasses( $this->getTitle() ); 3148 $bodyClasses[] = 'skin-' . Sanitizer::escapeClass( $sk->getSkinName() ); 3149 $bodyClasses[] = 3150 'action-' . Sanitizer::escapeClass( Action::getActionName( $this->getContext() ) ); 3151 3152 if ( $sk->isResponsive() ) { 3153 $bodyClasses[] = 'skin--responsive'; 3154 } 3155 3156 $bodyAttrs = []; 3157 // While the implode() is not strictly needed, it's used for backwards compatibility 3158 // (this used to be built as a string and hooks likely still expect that). 3159 $bodyAttrs['class'] = implode( ' ', $bodyClasses ); 3160 3161 // Allow skins and extensions to add body attributes they need 3162 // Get ones from deprecated method 3163 if ( method_exists( $sk, 'addToBodyAttributes' ) ) { 3164 /** @phan-suppress-next-line PhanUndeclaredMethod */ 3165 $sk->addToBodyAttributes( $this, $bodyAttrs ); 3166 wfDeprecated( 'Skin::addToBodyAttributes method to add body attributes', '1.35' ); 3167 } 3168 3169 // Then run the hook, the recommended way of adding body attributes now 3170 $this->getHookRunner()->onOutputPageBodyAttributes( $this, $sk, $bodyAttrs ); 3171 3172 $pieces[] = Html::openElement( 'body', $bodyAttrs ); 3173 3174 return self::combineWrappedStrings( $pieces ); 3175 } 3176 3177 /** 3178 * Get a ResourceLoader object associated with this OutputPage 3179 * 3180 * @return ResourceLoader 3181 */ 3182 public function getResourceLoader() { 3183 if ( $this->mResourceLoader === null ) { 3184 // Lazy-initialise as needed 3185 $this->mResourceLoader = MediaWikiServices::getInstance()->getResourceLoader(); 3186 } 3187 return $this->mResourceLoader; 3188 } 3189 3190 /** 3191 * Explicily load or embed modules on a page. 3192 * 3193 * @param array|string $modules One or more module names 3194 * @param string $only ResourceLoaderModule TYPE_ class constant 3195 * @param array $extraQuery [optional] Array with extra query parameters for the request 3196 * @return string|WrappedStringList HTML 3197 */ 3198 public function makeResourceLoaderLink( $modules, $only, array $extraQuery = [] ) { 3199 // Apply 'target' and 'origin' filters 3200 $modules = $this->filterModules( (array)$modules, null, $only ); 3201 3202 return ResourceLoaderClientHtml::makeLoad( 3203 $this->getRlClientContext(), 3204 $modules, 3205 $only, 3206 $extraQuery, 3207 $this->CSP->getNonce() 3208 ); 3209 } 3210 3211 /** 3212 * Combine WrappedString chunks and filter out empty ones 3213 * 3214 * @param array $chunks 3215 * @return string|WrappedStringList HTML 3216 */ 3217 protected static function combineWrappedStrings( array $chunks ) { 3218 // Filter out empty values 3219 $chunks = array_filter( $chunks, 'strlen' ); 3220 return WrappedString::join( "\n", $chunks ); 3221 } 3222 3223 /** 3224 * JS stuff to put at the bottom of the `<body>`. 3225 * These are legacy scripts ($this->mScripts), and user JS. 3226 * 3227 * @param string $extraHtml (only for use by this->tailElement(); will be removed in future) 3228 * @return string|WrappedStringList HTML 3229 */ 3230 public function getBottomScripts( $extraHtml = '' ) { 3231 $chunks = []; 3232 $chunks[] = $this->getRlClient()->getBodyHtml(); 3233 3234 // Legacy non-ResourceLoader scripts 3235 $chunks[] = $this->mScripts; 3236 3237 if ( $this->limitReportJSData ) { 3238 $chunks[] = ResourceLoader::makeInlineScript( 3239 ResourceLoader::makeConfigSetScript( 3240 [ 'wgPageParseReport' => $this->limitReportJSData ] 3241 ), 3242 $this->CSP->getNonce() 3243 ); 3244 } 3245 // This should be added last because the extra html comes from 3246 // SkinAfterBottomScripts hook. 3247 // TODO: Run the hook here directly and remove the parameter. 3248 $chunks[] = $extraHtml; 3249 3250 return self::combineWrappedStrings( $chunks ); 3251 } 3252 3253 /** 3254 * Get the javascript config vars to include on this page 3255 * 3256 * @return array Array of javascript config vars 3257 * @since 1.23 3258 */ 3259 public function getJsConfigVars() { 3260 return $this->mJsConfigVars; 3261 } 3262 3263 /** 3264 * Add one or more variables to be set in mw.config in JavaScript 3265 * 3266 * @param string|array $keys Key or array of key/value pairs 3267 * @param mixed|null $value [optional] Value of the configuration variable 3268 */ 3269 public function addJsConfigVars( $keys, $value = null ) { 3270 if ( is_array( $keys ) ) { 3271 foreach ( $keys as $key => $value ) { 3272 $this->mJsConfigVars[$key] = $value; 3273 } 3274 return; 3275 } 3276 3277 $this->mJsConfigVars[$keys] = $value; 3278 } 3279 3280 /** 3281 * Get an array containing the variables to be set in mw.config in JavaScript. 3282 * 3283 * Do not add things here which can be evaluated in ResourceLoaderStartUpModule 3284 * - in other words, page-independent/site-wide variables (without state). 3285 * You will only be adding bloat to the html page and causing page caches to 3286 * have to be purged on configuration changes. 3287 * @return array 3288 */ 3289 public function getJSVars() { 3290 $curRevisionId = 0; 3291 $articleId = 0; 3292 $canonicalSpecialPageName = false; # T23115 3293 $services = MediaWikiServices::getInstance(); 3294 3295 $title = $this->getTitle(); 3296 $ns = $title->getNamespace(); 3297 $nsInfo = $services->getNamespaceInfo(); 3298 $canonicalNamespace = $nsInfo->exists( $ns ) 3299 ? $nsInfo->getCanonicalName( $ns ) 3300 : $title->getNsText(); 3301 3302 $sk = $this->getSkin(); 3303 // Get the relevant title so that AJAX features can use the correct page name 3304 // when making API requests from certain special pages (T36972). 3305 $relevantTitle = $sk->getRelevantTitle(); 3306 3307 if ( $ns === NS_SPECIAL ) { 3308 list( $canonicalSpecialPageName, /*...*/ ) = 3309 $services->getSpecialPageFactory()-> 3310 resolveAlias( $title->getDBkey() ); 3311 } elseif ( $this->canUseWikiPage() ) { 3312 $wikiPage = $this->getWikiPage(); 3313 $curRevisionId = $wikiPage->getLatest(); 3314 $articleId = $wikiPage->getId(); 3315 } 3316 3317 $lang = $title->getPageViewLanguage(); 3318 3319 // Pre-process information 3320 $separatorTransTable = $lang->separatorTransformTable(); 3321 $separatorTransTable = $separatorTransTable ?: []; 3322 $compactSeparatorTransTable = [ 3323 implode( "\t", array_keys( $separatorTransTable ) ), 3324 implode( "\t", $separatorTransTable ), 3325 ]; 3326 $digitTransTable = $lang->digitTransformTable(); 3327 $digitTransTable = $digitTransTable ?: []; 3328 $compactDigitTransTable = [ 3329 implode( "\t", array_keys( $digitTransTable ) ), 3330 implode( "\t", $digitTransTable ), 3331 ]; 3332 3333 $user = $this->getUser(); 3334 3335 // Internal variables for MediaWiki core 3336 $vars = [ 3337 // @internal For mediawiki.page.ready 3338 'wgBreakFrames' => $this->getFrameOptions() == 'DENY', 3339 3340 // @internal For jquery.tablesorter 3341 'wgSeparatorTransformTable' => $compactSeparatorTransTable, 3342 'wgDigitTransformTable' => $compactDigitTransTable, 3343 'wgDefaultDateFormat' => $lang->getDefaultDateFormat(), 3344 'wgMonthNames' => $lang->getMonthNamesArray(), 3345 3346 // @internal For debugging purposes 3347 'wgRequestId' => WebRequest::getRequestId(), 3348 3349 // @internal For mw.loader 3350 'wgCSPNonce' => $this->CSP->getNonce(), 3351 ]; 3352 3353 // Start of supported and stable config vars (for use by extensions/gadgets). 3354 $vars += [ 3355 'wgCanonicalNamespace' => $canonicalNamespace, 3356 'wgCanonicalSpecialPageName' => $canonicalSpecialPageName, 3357 'wgNamespaceNumber' => $title->getNamespace(), 3358 'wgPageName' => $title->getPrefixedDBkey(), 3359 'wgTitle' => $title->getText(), 3360 'wgCurRevisionId' => $curRevisionId, 3361 'wgRevisionId' => (int)$this->getRevisionId(), 3362 'wgArticleId' => $articleId, 3363 'wgIsArticle' => $this->isArticle(), 3364 'wgIsRedirect' => $title->isRedirect(), 3365 'wgAction' => Action::getActionName( $this->getContext() ), 3366 'wgUserName' => $user->isAnon() ? null : $user->getName(), 3367 'wgUserGroups' => $services->getUserGroupManager()->getUserEffectiveGroups( $user ), 3368 'wgCategories' => $this->getCategories(), 3369 'wgPageContentLanguage' => $lang->getCode(), 3370 'wgPageContentModel' => $title->getContentModel(), 3371 'wgRelevantPageName' => $relevantTitle->getPrefixedDBkey(), 3372 'wgRelevantArticleId' => $relevantTitle->getArticleID(), 3373 ]; 3374 if ( $user->isRegistered() ) { 3375 $vars['wgUserId'] = $user->getId(); 3376 $vars['wgUserEditCount'] = $user->getEditCount(); 3377 $userReg = $user->getRegistration(); 3378 $vars['wgUserRegistration'] = $userReg ? (int)wfTimestamp( TS_UNIX, $userReg ) * 1000 : null; 3379 // Get the revision ID of the oldest new message on the user's talk 3380 // page. This can be used for constructing new message alerts on 3381 // the client side. 3382 $userNewMsgRevId = $this->getLastSeenUserTalkRevId(); 3383 // Only occupy precious space in the <head> when it is non-null (T53640) 3384 // mw.config.get returns null by default. 3385 if ( $userNewMsgRevId ) { 3386 $vars['wgUserNewMsgRevisionId'] = $userNewMsgRevId; 3387 } 3388 } 3389 $languageConverter = $services->getLanguageConverterFactory() 3390 ->getLanguageConverter( $services->getContentLanguage() ); 3391 if ( $languageConverter->hasVariants() ) { 3392 $vars['wgUserVariant'] = $languageConverter->getPreferredVariant(); 3393 } 3394 // Same test as SkinTemplate 3395 $vars['wgIsProbablyEditable'] = $this->getAuthority()->probablyCan( 'edit', $title ); 3396 $vars['wgRelevantPageIsProbablyEditable'] = $relevantTitle && 3397 $this->getAuthority()->probablyCan( 'edit', $relevantTitle ); 3398 foreach ( $title->getRestrictionTypes() as $type ) { 3399 // Following keys are set in $vars: 3400 // wgRestrictionCreate, wgRestrictionEdit, wgRestrictionMove, wgRestrictionUpload 3401 $vars['wgRestriction' . ucfirst( $type )] = $title->getRestrictions( $type ); 3402 } 3403 if ( $title->isMainPage() ) { 3404 $vars['wgIsMainPage'] = true; 3405 } 3406 3407 $relevantUser = $sk->getRelevantUser(); 3408 if ( $relevantUser ) { 3409 $vars['wgRelevantUserName'] = $relevantUser->getName(); 3410 } 3411 // End of stable config vars 3412 3413 $titleFormatter = $services->getTitleFormatter(); 3414 3415 if ( $this->mRedirectedFrom ) { 3416 // @internal For skin JS 3417 $vars['wgRedirectedFrom'] = $titleFormatter->getPrefixedDBkey( $this->mRedirectedFrom ); 3418 } 3419 3420 // Allow extensions to add their custom variables to the mw.config map. 3421 // Use the 'ResourceLoaderGetConfigVars' hook if the variable is not 3422 // page-dependent but site-wide (without state). 3423 // Alternatively, you may want to use OutputPage->addJsConfigVars() instead. 3424 $this->getHookRunner()->onMakeGlobalVariablesScript( $vars, $this ); 3425 3426 // Merge in variables from addJsConfigVars last 3427 return array_merge( $vars, $this->getJsConfigVars() ); 3428 } 3429 3430 /** 3431 * Get the revision ID for the last user talk page revision viewed by the talk page owner. 3432 * 3433 * @return int|null 3434 */ 3435 private function getLastSeenUserTalkRevId() { 3436 $services = MediaWikiServices::getInstance(); 3437 $user = $this->getUser(); 3438 $userHasNewMessages = $services 3439 ->getTalkPageNotificationManager() 3440 ->userHasNewMessages( $user ); 3441 if ( !$userHasNewMessages ) { 3442 return null; 3443 } 3444 3445 $timestamp = $services 3446 ->getTalkPageNotificationManager() 3447 ->getLatestSeenMessageTimestamp( $user ); 3448 3449 if ( !$timestamp ) { 3450 return null; 3451 } 3452 3453 $revRecord = $services->getRevisionLookup()->getRevisionByTimestamp( 3454 $user->getTalkPage(), 3455 $timestamp 3456 ); 3457 3458 if ( !$revRecord ) { 3459 return null; 3460 } 3461 3462 return $revRecord->getId(); 3463 } 3464 3465 /** 3466 * To make it harder for someone to slip a user a fake 3467 * JavaScript or CSS preview, a random token 3468 * is associated with the login session. If it's not 3469 * passed back with the preview request, we won't render 3470 * the code. 3471 * 3472 * @return bool 3473 */ 3474 public function userCanPreview() { 3475 $request = $this->getRequest(); 3476 if ( 3477 $request->getRawVal( 'action' ) !== 'submit' || 3478 !$request->wasPosted() 3479 ) { 3480 return false; 3481 } 3482 3483 $user = $this->getUser(); 3484 3485 if ( !$user->isRegistered() ) { 3486 // Anons have predictable edit tokens 3487 return false; 3488 } 3489 if ( !$user->matchEditToken( $request->getVal( 'wpEditToken' ) ) ) { 3490 return false; 3491 } 3492 3493 $title = $this->getTitle(); 3494 if ( !$this->getAuthority()->probablyCan( 'edit', $title ) ) { 3495 return false; 3496 } 3497 3498 return true; 3499 } 3500 3501 /** 3502 * @return array Array in format "link name or number => 'link html'". 3503 */ 3504 public function getHeadLinksArray() { 3505 $tags = []; 3506 $config = $this->getConfig(); 3507 3508 $canonicalUrl = $this->mCanonicalUrl; 3509 3510 $tags['meta-generator'] = Html::element( 'meta', [ 3511 'name' => 'generator', 3512 'content' => 'MediaWiki ' . MW_VERSION, 3513 ] ); 3514 3515 if ( $config->get( 'ReferrerPolicy' ) !== false ) { 3516 // Per https://w3c.github.io/webappsec-referrer-policy/#unknown-policy-values 3517 // fallbacks should come before the primary value so we need to reverse the array. 3518 foreach ( array_reverse( (array)$config->get( 'ReferrerPolicy' ) ) as $i => $policy ) { 3519 $tags["meta-referrer-$i"] = Html::element( 'meta', [ 3520 'name' => 'referrer', 3521 'content' => $policy, 3522 ] ); 3523 } 3524 } 3525 3526 $p = "{$this->mIndexPolicy},{$this->mFollowPolicy}"; 3527 if ( $p !== 'index,follow' ) { 3528 // http://www.robotstxt.org/wc/meta-user.html 3529 // Only show if it's different from the default robots policy 3530 $tags['meta-robots'] = Html::element( 'meta', [ 3531 'name' => 'robots', 3532 'content' => $p, 3533 ] ); 3534 } 3535 3536 # Browser based phonenumber detection 3537 if ( $config->get( 'BrowserFormatDetection' ) !== false ) { 3538 $tags['meta-format-detection'] = Html::element( 'meta', [ 3539 'name' => 'format-detection', 3540 'content' => $config->get( 'BrowserFormatDetection' ), 3541 ] ); 3542 } 3543 3544 foreach ( $this->mMetatags as $tag ) { 3545 if ( strncasecmp( $tag[0], 'http:', 5 ) === 0 ) { 3546 $a = 'http-equiv'; 3547 $tag[0] = substr( $tag[0], 5 ); 3548 } elseif ( strncasecmp( $tag[0], 'og:', 3 ) === 0 ) { 3549 $a = 'property'; 3550 } else { 3551 $a = 'name'; 3552 } 3553 $tagName = "meta-{$tag[0]}"; 3554 if ( isset( $tags[$tagName] ) ) { 3555 $tagName .= $tag[1]; 3556 } 3557 $tags[$tagName] = Html::element( 'meta', 3558 [ 3559 $a => $tag[0], 3560 'content' => $tag[1] 3561 ] 3562 ); 3563 } 3564 3565 foreach ( $this->mLinktags as $tag ) { 3566 $tags[] = Html::element( 'link', $tag ); 3567 } 3568 3569 # Universal edit button 3570 if ( $config->get( 'UniversalEditButton' ) && $this->isArticleRelated() ) { 3571 if ( $this->getAuthority()->probablyCan( 'edit', $this->getTitle() ) ) { 3572 // Original UniversalEditButton 3573 $msg = $this->msg( 'edit' )->text(); 3574 $tags['universal-edit-button'] = Html::element( 'link', [ 3575 'rel' => 'alternate', 3576 'type' => 'application/x-wiki', 3577 'title' => $msg, 3578 'href' => $this->getTitle()->getEditURL(), 3579 ] ); 3580 // Alternate edit link 3581 $tags['alternative-edit'] = Html::element( 'link', [ 3582 'rel' => 'edit', 3583 'title' => $msg, 3584 'href' => $this->getTitle()->getEditURL(), 3585 ] ); 3586 } 3587 } 3588 3589 # Generally the order of the favicon and apple-touch-icon links 3590 # should not matter, but Konqueror (3.5.9 at least) incorrectly 3591 # uses whichever one appears later in the HTML source. Make sure 3592 # apple-touch-icon is specified first to avoid this. 3593 if ( $config->get( 'AppleTouchIcon' ) !== false ) { 3594 $tags['apple-touch-icon'] = Html::element( 'link', [ 3595 'rel' => 'apple-touch-icon', 3596 'href' => $config->get( 'AppleTouchIcon' ) 3597 ] ); 3598 } 3599 3600 if ( $config->get( 'Favicon' ) !== false ) { 3601 $tags['favicon'] = Html::element( 'link', [ 3602 'rel' => 'shortcut icon', 3603 'href' => $config->get( 'Favicon' ) 3604 ] ); 3605 } 3606 3607 # OpenSearch description link 3608 $tags['opensearch'] = Html::element( 'link', [ 3609 'rel' => 'search', 3610 'type' => 'application/opensearchdescription+xml', 3611 'href' => wfScript( 'opensearch_desc' ), 3612 'title' => $this->msg( 'opensearch-desc' )->inContentLanguage()->text(), 3613 ] ); 3614 3615 # Real Simple Discovery link, provides auto-discovery information 3616 # for the MediaWiki API (and potentially additional custom API 3617 # support such as WordPress or Twitter-compatible APIs for a 3618 # blogging extension, etc) 3619 $tags['rsd'] = Html::element( 'link', [ 3620 'rel' => 'EditURI', 3621 'type' => 'application/rsd+xml', 3622 // Output a protocol-relative URL here if $wgServer is protocol-relative. 3623 // Whether RSD accepts relative or protocol-relative URLs is completely 3624 // undocumented, though. 3625 'href' => wfExpandUrl( wfAppendQuery( 3626 wfScript( 'api' ), 3627 [ 'action' => 'rsd' ] ), 3628 PROTO_RELATIVE 3629 ), 3630 ] ); 3631 3632 # Language variants 3633 $services = MediaWikiServices::getInstance(); 3634 $languageConverterFactory = $services->getLanguageConverterFactory(); 3635 $disableLangConversion = $languageConverterFactory->isConversionDisabled(); 3636 if ( !$disableLangConversion ) { 3637 $lang = $this->getTitle()->getPageLanguage(); 3638 $languageConverter = $languageConverterFactory->getLanguageConverter( $lang ); 3639 if ( $languageConverter->hasVariants() ) { 3640 $variants = $languageConverter->getVariants(); 3641 foreach ( $variants as $variant ) { 3642 $tags["variant-$variant"] = Html::element( 'link', [ 3643 'rel' => 'alternate', 3644 'hreflang' => LanguageCode::bcp47( $variant ), 3645 'href' => $this->getTitle()->getLocalURL( 3646 [ 'variant' => $variant ] ) 3647 ] 3648 ); 3649 } 3650 # x-default link per https://support.google.com/webmasters/answer/189077?hl=en 3651 $tags["variant-x-default"] = Html::element( 'link', [ 3652 'rel' => 'alternate', 3653 'hreflang' => 'x-default', 3654 'href' => $this->getTitle()->getLocalURL() ] ); 3655 } 3656 } 3657 3658 # Copyright 3659 if ( $this->copyrightUrl !== null ) { 3660 $copyright = $this->copyrightUrl; 3661 } else { 3662 $copyright = ''; 3663 if ( $config->get( 'RightsPage' ) ) { 3664 $copy = Title::newFromText( $config->get( 'RightsPage' ) ); 3665 3666 if ( $copy ) { 3667 $copyright = $copy->getLocalURL(); 3668 } 3669 } 3670 3671 if ( !$copyright && $config->get( 'RightsUrl' ) ) { 3672 $copyright = $config->get( 'RightsUrl' ); 3673 } 3674 } 3675 3676 if ( $copyright ) { 3677 $tags['copyright'] = Html::element( 'link', [ 3678 'rel' => 'license', 3679 'href' => $copyright ] 3680 ); 3681 } 3682 3683 # Feeds 3684 if ( $config->get( 'Feed' ) ) { 3685 $feedLinks = []; 3686 3687 foreach ( $this->getSyndicationLinks() as $format => $link ) { 3688 # Use the page name for the title. In principle, this could 3689 # lead to issues with having the same name for different feeds 3690 # corresponding to the same page, but we can't avoid that at 3691 # this low a level. 3692 3693 $feedLinks[] = $this->feedLink( 3694 $format, 3695 $link, 3696 # Used messages: 'page-rss-feed' and 'page-atom-feed' (for an easier grep) 3697 $this->msg( 3698 "page-{$format}-feed", $this->getTitle()->getPrefixedText() 3699 )->text() 3700 ); 3701 } 3702 3703 # Recent changes feed should appear on every page (except recentchanges, 3704 # that would be redundant). Put it after the per-page feed to avoid 3705 # changing existing behavior. It's still available, probably via a 3706 # menu in your browser. Some sites might have a different feed they'd 3707 # like to promote instead of the RC feed (maybe like a "Recent New Articles" 3708 # or "Breaking news" one). For this, we see if $wgOverrideSiteFeed is defined. 3709 # If so, use it instead. 3710 $sitename = $config->get( 'Sitename' ); 3711 $overrideSiteFeed = $config->get( 'OverrideSiteFeed' ); 3712 if ( $overrideSiteFeed ) { 3713 foreach ( $overrideSiteFeed as $type => $feedUrl ) { 3714 // Note, this->feedLink escapes the url. 3715 $feedLinks[] = $this->feedLink( 3716 $type, 3717 $feedUrl, 3718 $this->msg( "site-{$type}-feed", $sitename )->text() 3719 ); 3720 } 3721 } elseif ( !$this->getTitle()->isSpecial( 'Recentchanges' ) ) { 3722 $rctitle = SpecialPage::getTitleFor( 'Recentchanges' ); 3723 foreach ( $this->getAdvertisedFeedTypes() as $format ) { 3724 $feedLinks[] = $this->feedLink( 3725 $format, 3726 $rctitle->getLocalURL( [ 'feed' => $format ] ), 3727 # For grep: 'site-rss-feed', 'site-atom-feed' 3728 $this->msg( "site-{$format}-feed", $sitename )->text() 3729 ); 3730 } 3731 } 3732 3733 # Allow extensions to change the list pf feeds. This hook is primarily for changing, 3734 # manipulating or removing existing feed tags. If you want to add new feeds, you should 3735 # use OutputPage::addFeedLink() instead. 3736 $this->getHookRunner()->onAfterBuildFeedLinks( $feedLinks ); 3737 3738 $tags += $feedLinks; 3739 } 3740 3741 # Canonical URL 3742 if ( $config->get( 'EnableCanonicalServerLink' ) ) { 3743 if ( $canonicalUrl !== false ) { 3744 $canonicalUrl = wfExpandUrl( $canonicalUrl, PROTO_CANONICAL ); 3745 } elseif ( $this->isArticleRelated() ) { 3746 // This affects all requests where "setArticleRelated" is true. This is 3747 // typically all requests that show content (query title, curid, oldid, diff), 3748 // and all wikipage actions (edit, delete, purge, info, history etc.). 3749 // It does not apply to File pages and Special pages. 3750 // 'history' and 'info' actions address page metadata rather than the page 3751 // content itself, so they may not be canonicalized to the view page url. 3752 // TODO: this ought to be better encapsulated in the Action class. 3753 $action = Action::getActionName( $this->getContext() ); 3754 if ( in_array( $action, [ 'history', 'info' ] ) ) { 3755 $query = "action={$action}"; 3756 } else { 3757 $query = ''; 3758 } 3759 $canonicalUrl = $this->getTitle()->getCanonicalURL( $query ); 3760 } else { 3761 $reqUrl = $this->getRequest()->getRequestURL(); 3762 $canonicalUrl = wfExpandUrl( $reqUrl, PROTO_CANONICAL ); 3763 } 3764 } 3765 if ( $canonicalUrl !== false ) { 3766 $tags[] = Html::element( 'link', [ 3767 'rel' => 'canonical', 3768 'href' => $canonicalUrl 3769 ] ); 3770 } 3771 3772 // Allow extensions to add, remove and/or otherwise manipulate these links 3773 // If you want only to *add* <head> links, please use the addHeadItem() 3774 // (or addHeadItems() for multiple items) method instead. 3775 // This hook is provided as a last resort for extensions to modify these 3776 // links before the output is sent to client. 3777 $this->getHookRunner()->onOutputPageAfterGetHeadLinksArray( $tags, $this ); 3778 3779 return $tags; 3780 } 3781 3782 /** 3783 * Generate a "<link rel/>" for a feed. 3784 * 3785 * @param string $type Feed type 3786 * @param string $url URL to the feed 3787 * @param string $text Value of the "title" attribute 3788 * @return string HTML fragment 3789 */ 3790 private function feedLink( $type, $url, $text ) { 3791 return Html::element( 'link', [ 3792 'rel' => 'alternate', 3793 'type' => "application/$type+xml", 3794 'title' => $text, 3795 'href' => $url ] 3796 ); 3797 } 3798 3799 /** 3800 * Add a local or specified stylesheet, with the given media options. 3801 * Internal use only. Use OutputPage::addModuleStyles() if possible. 3802 * 3803 * @param string $style URL to the file 3804 * @param string $media To specify a media type, 'screen', 'printable', 'handheld' or any. 3805 * @param string $condition For IE conditional comments, specifying an IE version 3806 * @param string $dir Set to 'rtl' or 'ltr' for direction-specific sheets 3807 */ 3808 public function addStyle( $style, $media = '', $condition = '', $dir = '' ) { 3809 $options = []; 3810 if ( $media ) { 3811 $options['media'] = $media; 3812 } 3813 if ( $condition ) { 3814 $options['condition'] = $condition; 3815 } 3816 if ( $dir ) { 3817 $options['dir'] = $dir; 3818 } 3819 $this->styles[$style] = $options; 3820 } 3821 3822 /** 3823 * Adds inline CSS styles 3824 * Internal use only. Use OutputPage::addModuleStyles() if possible. 3825 * 3826 * @param mixed $style_css Inline CSS 3827 * @param string $flip Set to 'flip' to flip the CSS if needed 3828 */ 3829 public function addInlineStyle( $style_css, $flip = 'noflip' ) { 3830 if ( $flip === 'flip' && $this->getLanguage()->isRTL() ) { 3831 # If wanted, and the interface is right-to-left, flip the CSS 3832 $style_css = CSSJanus::transform( $style_css, true, false ); 3833 } 3834 $this->mInlineStyles .= Html::inlineStyle( $style_css ); 3835 } 3836 3837 /** 3838 * Build exempt modules and legacy non-ResourceLoader styles. 3839 * 3840 * @return string|WrappedStringList HTML 3841 */ 3842 protected function buildExemptModules() { 3843 $chunks = []; 3844 3845 // Requirements: 3846 // - Within modules provided by the software (core, skin, extensions), 3847 // styles from skin stylesheets should be overridden by styles 3848 // from modules dynamically loaded with JavaScript. 3849 // - Styles from site-specific, private, and user modules should override 3850 // both of the above. 3851 // 3852 // The effective order for stylesheets must thus be: 3853 // 1. Page style modules, formatted server-side by ResourceLoaderClientHtml. 3854 // 2. Dynamically-loaded styles, inserted client-side by mw.loader. 3855 // 3. Styles that are site-specific, private or from the user, formatted 3856 // server-side by this function. 3857 // 3858 // The 'ResourceLoaderDynamicStyles' marker helps JavaScript know where 3859 // point #2 is. 3860 3861 // Add legacy styles added through addStyle()/addInlineStyle() here 3862 $chunks[] = implode( '', $this->buildCssLinksArray() ) . $this->mInlineStyles; 3863 3864 // Things that go after the ResourceLoaderDynamicStyles marker 3865 $append = []; 3866 $separateReq = [ 'site.styles', 'user.styles' ]; 3867 foreach ( $this->rlExemptStyleModules as $group => $moduleNames ) { 3868 if ( $moduleNames ) { 3869 $append[] = $this->makeResourceLoaderLink( 3870 array_diff( $moduleNames, $separateReq ), 3871 ResourceLoaderModule::TYPE_STYLES 3872 ); 3873 3874 foreach ( array_intersect( $moduleNames, $separateReq ) as $name ) { 3875 // These require their own dedicated request in order to support "@import" 3876 // syntax, which is incompatible with concatenation. (T147667, T37562) 3877 $append[] = $this->makeResourceLoaderLink( $name, 3878 ResourceLoaderModule::TYPE_STYLES 3879 ); 3880 } 3881 } 3882 } 3883 if ( $append ) { 3884 $chunks[] = Html::element( 3885 'meta', 3886 [ 'name' => 'ResourceLoaderDynamicStyles', 'content' => '' ] 3887 ); 3888 $chunks = array_merge( $chunks, $append ); 3889 } 3890 3891 return self::combineWrappedStrings( $chunks ); 3892 } 3893 3894 /** 3895 * @return array 3896 */ 3897 public function buildCssLinksArray() { 3898 $links = []; 3899 3900 foreach ( $this->styles as $file => $options ) { 3901 $link = $this->styleLink( $file, $options ); 3902 if ( $link ) { 3903 $links[$file] = $link; 3904 } 3905 } 3906 return $links; 3907 } 3908 3909 /** 3910 * Generate \<link\> tags for stylesheets 3911 * 3912 * @param string $style URL to the file 3913 * @param array $options Option, can contain 'condition', 'dir', 'media' keys 3914 * @return string HTML fragment 3915 */ 3916 protected function styleLink( $style, array $options ) { 3917 if ( isset( $options['dir'] ) && $this->getLanguage()->getDir() != $options['dir'] ) { 3918 return ''; 3919 } 3920 3921 if ( isset( $options['media'] ) ) { 3922 $media = self::transformCssMedia( $options['media'] ); 3923 if ( $media === null ) { 3924 return ''; 3925 } 3926 } else { 3927 $media = 'all'; 3928 } 3929 3930 if ( substr( $style, 0, 1 ) == '/' || 3931 substr( $style, 0, 5 ) == 'http:' || 3932 substr( $style, 0, 6 ) == 'https:' ) { 3933 $url = $style; 3934 } else { 3935 $config = $this->getConfig(); 3936 // Append file hash as query parameter 3937 $url = self::transformResourcePath( 3938 $config, 3939 $config->get( 'StylePath' ) . '/' . $style 3940 ); 3941 } 3942 3943 $link = Html::linkedStyle( $url, $media ); 3944 3945 if ( isset( $options['condition'] ) ) { 3946 $condition = htmlspecialchars( $options['condition'] ); 3947 $link = "<!--[if $condition]>$link<![endif]-->"; 3948 } 3949 return $link; 3950 } 3951 3952 /** 3953 * Transform path to web-accessible static resource. 3954 * 3955 * This is used to add a validation hash as query string. 3956 * This aids various behaviors: 3957 * 3958 * - Put long Cache-Control max-age headers on responses for improved 3959 * cache performance. 3960 * - Get the correct version of a file as expected by the current page. 3961 * - Instantly get the updated version of a file after deployment. 3962 * 3963 * Avoid using this for urls included in HTML as otherwise clients may get different 3964 * versions of a resource when navigating the site depending on when the page was cached. 3965 * If changes to the url propagate, this is not a problem (e.g. if the url is in 3966 * an external stylesheet). 3967 * 3968 * @since 1.27 3969 * @param Config $config 3970 * @param string $path Path-absolute URL to file (from document root, must start with "/") 3971 * @return string URL 3972 */ 3973 public static function transformResourcePath( Config $config, $path ) { 3974 global $IP; 3975 3976 $localDir = $IP; 3977 $remotePathPrefix = $config->get( 'ResourceBasePath' ); 3978 if ( $remotePathPrefix === '' ) { 3979 // The configured base path is required to be empty string for 3980 // wikis in the domain root 3981 $remotePath = '/'; 3982 } else { 3983 $remotePath = $remotePathPrefix; 3984 } 3985 if ( strpos( $path, $remotePath ) !== 0 || substr( $path, 0, 2 ) === '//' ) { 3986 // - Path is outside wgResourceBasePath, ignore. 3987 // - Path is protocol-relative. Fixes T155310. Not supported by RelPath lib. 3988 return $path; 3989 } 3990 // For files in resources, extensions/ or skins/, ResourceBasePath is preferred here. 3991 // For other misc files in $IP, we'll fallback to that as well. There is, however, a fourth 3992 // supported dir/path pair in the configuration (wgUploadDirectory, wgUploadPath) 3993 // which is not expected to be in wgResourceBasePath on CDNs. (T155146) 3994 $uploadPath = $config->get( 'UploadPath' ); 3995 if ( strpos( $path, $uploadPath ) === 0 ) { 3996 $localDir = $config->get( 'UploadDirectory' ); 3997 $remotePathPrefix = $remotePath = $uploadPath; 3998 } 3999 4000 $path = RelPath::getRelativePath( $path, $remotePath ); 4001 return self::transformFilePath( $remotePathPrefix, $localDir, $path ); 4002 } 4003 4004 /** 4005 * Utility method for transformResourceFilePath(). 4006 * 4007 * Caller is responsible for ensuring the file exists. Emits a PHP warning otherwise. 4008 * 4009 * @since 1.27 4010 * @param string $remotePathPrefix URL path prefix that points to $localPath 4011 * @param string $localPath File directory exposed at $remotePath 4012 * @param string $file Path to target file relative to $localPath 4013 * @return string URL 4014 */ 4015 public static function transformFilePath( $remotePathPrefix, $localPath, $file ) { 4016 // This MUST match the equivalent logic in CSSMin::remapOne() 4017 $localFile = "$localPath/$file"; 4018 $url = "$remotePathPrefix/$file"; 4019 if ( file_exists( $localFile ) ) { 4020 $hash = md5_file( $localFile ); 4021 if ( $hash === false ) { 4022 wfLogWarning( __METHOD__ . ": Failed to hash $localFile" ); 4023 $hash = ''; 4024 } 4025 $url .= '?' . substr( $hash, 0, 5 ); 4026 } 4027 return $url; 4028 } 4029 4030 /** 4031 * Transform "media" attribute based on request parameters 4032 * 4033 * @param string $media Current value of the "media" attribute 4034 * @return string|null Modified value of the "media" attribute, or null to skip 4035 * this stylesheet 4036 */ 4037 public static function transformCssMedia( $media ) { 4038 global $wgRequest; 4039 4040 // https://www.w3.org/TR/css3-mediaqueries/#syntax 4041 $screenMediaQueryRegex = '/^(?:only\s+)?screen\b/i'; 4042 4043 // Switch in on-screen display for media testing 4044 $switches = [ 4045 'printable' => 'print', 4046 'handheld' => 'handheld', 4047 ]; 4048 foreach ( $switches as $switch => $targetMedia ) { 4049 if ( $wgRequest->getBool( $switch ) ) { 4050 if ( $media == $targetMedia ) { 4051 $media = ''; 4052 } elseif ( preg_match( $screenMediaQueryRegex, $media ) === 1 ) { 4053 /* This regex will not attempt to understand a comma-separated media_query_list 4054 * 4055 * Example supported values for $media: 4056 * 'screen', 'only screen', 'screen and (min-width: 982px)' ), 4057 * Example NOT supported value for $media: 4058 * '3d-glasses, screen, print and resolution > 90dpi' 4059 * 4060 * If it's a print request, we never want any kind of screen stylesheets 4061 * If it's a handheld request (currently the only other choice with a switch), 4062 * we don't want simple 'screen' but we might want screen queries that 4063 * have a max-width or something, so we'll pass all others on and let the 4064 * client do the query. 4065 */ 4066 if ( $targetMedia == 'print' || $media == 'screen' ) { 4067 return null; 4068 } 4069 } 4070 } 4071 } 4072 4073 return $media; 4074 } 4075 4076 /** 4077 * Add a wikitext-formatted message to the output. 4078 * This is equivalent to: 4079 * 4080 * $wgOut->addWikiText( wfMessage( ... )->plain() ) 4081 * 4082 * @param mixed ...$args 4083 */ 4084 public function addWikiMsg( ...$args ) { 4085 $name = array_shift( $args ); 4086 $this->addWikiMsgArray( $name, $args ); 4087 } 4088 4089 /** 4090 * Add a wikitext-formatted message to the output. 4091 * Like addWikiMsg() except the parameters are taken as an array 4092 * instead of a variable argument list. 4093 * 4094 * @param string $name 4095 * @param array $args 4096 */ 4097 public function addWikiMsgArray( $name, $args ) { 4098 $this->addHTML( $this->msg( $name, $args )->parseAsBlock() ); 4099 } 4100 4101 /** 4102 * This function takes a number of message/argument specifications, wraps them in 4103 * some overall structure, and then parses the result and adds it to the output. 4104 * 4105 * In the $wrap, $1 is replaced with the first message, $2 with the second, 4106 * and so on. The subsequent arguments may be either 4107 * 1) strings, in which case they are message names, or 4108 * 2) arrays, in which case, within each array, the first element is the message 4109 * name, and subsequent elements are the parameters to that message. 4110 * 4111 * Don't use this for messages that are not in the user's interface language. 4112 * 4113 * For example: 4114 * 4115 * $wgOut->wrapWikiMsg( "<div class='errorbox'>\n$1\n</div>", 'some-error' ); 4116 * 4117 * Is equivalent to: 4118 * 4119 * $wgOut->addWikiTextAsInterface( "<div class='errorbox'>\n" 4120 * . wfMessage( 'some-error' )->plain() . "\n</div>" ); 4121 * 4122 * The newline after the opening div is needed in some wikitext. See T21226. 4123 * 4124 * @param string $wrap 4125 * @param mixed ...$msgSpecs 4126 */ 4127 public function wrapWikiMsg( $wrap, ...$msgSpecs ) { 4128 $s = $wrap; 4129 foreach ( $msgSpecs as $n => $spec ) { 4130 if ( is_array( $spec ) ) { 4131 $args = $spec; 4132 $name = array_shift( $args ); 4133 } else { 4134 $args = []; 4135 $name = $spec; 4136 } 4137 $s = str_replace( '$' . ( $n + 1 ), $this->msg( $name, $args )->plain(), $s ); 4138 } 4139 $this->addWikiTextAsInterface( $s ); 4140 } 4141 4142 /** 4143 * Whether the output has a table of contents 4144 * @return bool 4145 * @since 1.22 4146 */ 4147 public function isTOCEnabled() { 4148 return $this->mEnableTOC; 4149 } 4150 4151 /** 4152 * Helper function to setup the PHP implementation of OOUI to use in this request. 4153 * 4154 * @since 1.26 4155 * @param string $skinName The Skin name to determine the correct OOUI theme 4156 * @param string $dir Language direction 4157 */ 4158 public static function setupOOUI( $skinName = 'default', $dir = 'ltr' ) { 4159 $themes = ResourceLoaderOOUIModule::getSkinThemeMap(); 4160 $theme = $themes[$skinName] ?? $themes['default']; 4161 // For example, 'OOUI\WikimediaUITheme'. 4162 $themeClass = "OOUI\\{$theme}Theme"; 4163 OOUI\Theme::setSingleton( new $themeClass() ); 4164 OOUI\Element::setDefaultDir( $dir ); 4165 } 4166 4167 /** 4168 * Add ResourceLoader module styles for OOUI and set up the PHP implementation of it for use with 4169 * MediaWiki and this OutputPage instance. 4170 * 4171 * @since 1.25 4172 */ 4173 public function enableOOUI() { 4174 self::setupOOUI( 4175 strtolower( $this->getSkin()->getSkinName() ), 4176 $this->getLanguage()->getDir() 4177 ); 4178 $this->addModuleStyles( [ 4179 'oojs-ui-core.styles', 4180 'oojs-ui.styles.indicators', 4181 'mediawiki.widgets.styles', 4182 'oojs-ui-core.icons', 4183 ] ); 4184 } 4185 4186 /** 4187 * Get (and set if not yet set) the CSP nonce. 4188 * 4189 * This value needs to be included in any <script> tags on the 4190 * page. 4191 * 4192 * @return string|bool Nonce or false to mean don't output nonce 4193 * @since 1.32 4194 * @deprecated Since 1.35 use getCSP()->getNonce() instead 4195 */ 4196 public function getCSPNonce() { 4197 return $this->CSP->getNonce(); 4198 } 4199 4200 /** 4201 * Get the ContentSecurityPolicy object 4202 * 4203 * @since 1.35 4204 * @return ContentSecurityPolicy 4205 */ 4206 public function getCSP() { 4207 return $this->CSP; 4208 } 4209 4210 /** 4211 * The final bits that go to the bottom of a page 4212 * HTML document including the closing tags 4213 * 4214 * @internal 4215 * @since 1.37 4216 * @param Skin $skin 4217 * @return string 4218 */ 4219 public function tailElement( $skin ) { 4220 // T257704: Temporarily run skin hook here pending 4221 // creation dedicated outputpage hook for this 4222 $extraHtml = ''; 4223 $this->getHookRunner()->onSkinAfterBottomScripts( $skin, $extraHtml ); 4224 4225 $tail = [ 4226 MWDebug::getDebugHTML( $skin ), 4227 $this->getBottomScripts( $extraHtml ), 4228 wfReportTime( $this->getCSP()->getNonce() ), 4229 MWDebug::getHTMLDebugLog() 4230 . Html::closeElement( 'body' ) 4231 . Html::closeElement( 'html' ) 4232 ]; 4233 4234 return WrappedStringList::join( "\n", $tail ); 4235 } 4236} 4237