3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
21 use MediaWiki\Block\DatabaseBlock
;
22 use MediaWiki\Block\DatabaseBlockStore
;
23 use MediaWiki\CommentFormatter\CommentFormatter
;
24 use MediaWiki\Context\IContextSource
;
25 use MediaWiki\Context\RequestContext
;
26 use MediaWiki\EditPage\EditPage
;
27 use MediaWiki\HookContainer\HookRunner
;
28 use MediaWiki\HookContainer\ProtectedHookAccessorTrait
;
29 use MediaWiki\Html\Html
;
30 use MediaWiki\Language\Language
;
31 use MediaWiki\Linker\Linker
;
32 use MediaWiki\Linker\LinkRenderer
;
33 use MediaWiki\MainConfigNames
;
34 use MediaWiki\MediaWikiServices
;
35 use MediaWiki\Message\Message
;
36 use MediaWiki\Output\OutputPage
;
37 use MediaWiki\Page\ParserOutputAccess
;
38 use MediaWiki\Page\ProtectionForm
;
39 use MediaWiki\Page\WikiPageFactory
;
40 use MediaWiki\Parser\Parser
;
41 use MediaWiki\Parser\ParserOptions
;
42 use MediaWiki\Parser\ParserOutput
;
43 use MediaWiki\Permissions\Authority
;
44 use MediaWiki\Permissions\PermissionStatus
;
45 use MediaWiki\Permissions\RestrictionStore
;
46 use MediaWiki\Revision\ArchivedRevisionLookup
;
47 use MediaWiki\Revision\BadRevisionException
;
48 use MediaWiki\Revision\RevisionRecord
;
49 use MediaWiki\Revision\RevisionStore
;
50 use MediaWiki\Revision\SlotRecord
;
51 use MediaWiki\Status\Status
;
52 use MediaWiki\Title\Title
;
53 use MediaWiki\User\Options\UserOptionsLookup
;
54 use MediaWiki\User\UserIdentity
;
55 use MediaWiki\User\UserNameUtils
;
56 use MediaWiki\Xml\Xml
;
57 use Wikimedia\IPUtils
;
58 use Wikimedia\LightweightObjectStore\ExpirationAwareness
;
59 use Wikimedia\NonSerializable\NonSerializableTrait
;
60 use Wikimedia\Rdbms\IConnectionProvider
;
63 * Legacy class representing an editable page and handling UI for some page actions.
65 * This has largely been superseded by WikiPage, with Action subclasses for the
66 * user interface of page actions, and service classes for their backend logic.
68 * @todo Move and refactor remaining code
71 class Article
implements Page
{
72 use ProtectedHookAccessorTrait
;
73 use NonSerializableTrait
;
76 * @var IContextSource|null The context this Article is executed in.
77 * If null, RequestContext::getMain() is used.
78 * @deprecated since 1.35, must be private, use {@link getContext}
82 /** @var WikiPage The WikiPage object of this instance */
86 * @var int|null The oldid of the article that was requested to be shown,
87 * 0 for the current revision.
91 /** @var Title|null Title from which we were redirected here, if any. */
92 public $mRedirectedFrom = null;
94 /** @var string|false URL to redirect to or false if none */
95 public $mRedirectUrl = false;
98 * @var Status|null represents the outcome of fetchRevisionRecord().
99 * $fetchResult->value is the RevisionRecord object, if the operation was successful.
101 private $fetchResult = null;
104 * @var ParserOutput|null|false The ParserOutput generated for viewing the page,
105 * initialized by view(). If no ParserOutput could be generated, this is set to false.
106 * @deprecated since 1.32
108 public $mParserOutput = null;
111 * @var bool Whether render() was called. With the way subclasses work
112 * here, there doesn't seem to be any other way to stop calling
113 * OutputPage::enableSectionEditLinks() and still have it work as it did before.
115 protected $viewIsRenderAction = false;
117 protected LinkRenderer
$linkRenderer;
118 private RevisionStore
$revisionStore;
119 private UserNameUtils
$userNameUtils;
120 private UserOptionsLookup
$userOptionsLookup;
121 private CommentFormatter
$commentFormatter;
122 private WikiPageFactory
$wikiPageFactory;
123 private JobQueueGroup
$jobQueueGroup;
124 private ArchivedRevisionLookup
$archivedRevisionLookup;
125 protected IConnectionProvider
$dbProvider;
126 protected DatabaseBlockStore
$blockStore;
128 protected RestrictionStore
$restrictionStore;
131 * @var RevisionRecord|null Revision to be shown
133 * Initialized by getOldIDFromRequest() or fetchRevisionRecord(). While the output of
134 * Article::view is typically based on this revision, it may be replaced by extensions.
136 private $mRevisionRecord = null;
139 * @param Title $title
140 * @param int|null $oldId Revision ID, null to fetch from request, zero for current
142 public function __construct( Title
$title, $oldId = null ) {
143 $this->mOldId
= $oldId;
144 $this->mPage
= $this->newPage( $title );
146 $services = MediaWikiServices
::getInstance();
147 $this->linkRenderer
= $services->getLinkRenderer();
148 $this->revisionStore
= $services->getRevisionStore();
149 $this->userNameUtils
= $services->getUserNameUtils();
150 $this->userOptionsLookup
= $services->getUserOptionsLookup();
151 $this->commentFormatter
= $services->getCommentFormatter();
152 $this->wikiPageFactory
= $services->getWikiPageFactory();
153 $this->jobQueueGroup
= $services->getJobQueueGroup();
154 $this->archivedRevisionLookup
= $services->getArchivedRevisionLookup();
155 $this->dbProvider
= $services->getConnectionProvider();
156 $this->blockStore
= $services->getDatabaseBlockStore();
157 $this->restrictionStore
= $services->getRestrictionStore();
161 * @param Title $title
164 protected function newPage( Title
$title ) {
165 return new WikiPage( $title );
169 * Constructor from a page id
170 * @param int $id Article ID to load
171 * @return Article|null
173 public static function newFromID( $id ) {
174 $t = Title
::newFromID( $id );
175 return $t === null ?
null : new static( $t );
179 * Create an Article object of the appropriate class for the given page.
181 * @param Title $title
182 * @param IContextSource $context
185 public static function newFromTitle( $title, IContextSource
$context ): self
{
186 if ( $title->getNamespace() === NS_MEDIA
) {
187 // XXX: This should not be here, but where should it go?
188 $title = Title
::makeTitle( NS_FILE
, $title->getDBkey() );
192 ( new HookRunner( MediaWikiServices
::getInstance()->getHookContainer() ) )
193 // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
194 ->onArticleFromTitle( $title, $page, $context );
196 switch ( $title->getNamespace() ) {
198 $page = new ImagePage( $title );
201 $page = new CategoryPage( $title );
204 $page = new Article( $title );
207 $page->setContext( $context );
213 * Create an Article object of the appropriate class for the given page.
215 * @param WikiPage $page
216 * @param IContextSource $context
219 public static function newFromWikiPage( WikiPage
$page, IContextSource
$context ) {
220 $article = self
::newFromTitle( $page->getTitle(), $context );
221 $article->mPage
= $page; // override to keep process cached vars
226 * Get the page this view was redirected from
230 public function getRedirectedFrom() {
231 return $this->mRedirectedFrom
;
235 * Tell the page view functions that this view was redirected
236 * from another page on the wiki.
239 public function setRedirectedFrom( Title
$from ) {
240 $this->mRedirectedFrom
= $from;
244 * Get the title object of the article
246 * @return Title Title object of this page
248 public function getTitle() {
249 return $this->mPage
->getTitle();
253 * Get the WikiPage object of this instance
258 public function getPage() {
262 public function clear() {
263 $this->mRedirectedFrom
= null; # Title object if set
264 $this->mRedirectUrl
= false;
265 $this->mRevisionRecord
= null;
266 $this->fetchResult
= null;
268 // TODO hard-deprecate direct access to public fields
270 $this->mPage
->clear();
274 * @see getOldIDFromRequest()
275 * @see getRevIdFetched()
277 * @return int The oldid of the article that is was requested in the constructor or via the
278 * context's WebRequest.
280 public function getOldID() {
281 if ( $this->mOldId
=== null ) {
282 $this->mOldId
= $this->getOldIDFromRequest();
285 return $this->mOldId
;
289 * Sets $this->mRedirectUrl to a correct URL if the query parameters are incorrect
291 * @return int The old id for the request
293 public function getOldIDFromRequest() {
294 $this->mRedirectUrl
= false;
296 $request = $this->getContext()->getRequest();
297 $oldid = $request->getIntOrNull( 'oldid' );
299 if ( $oldid === null ) {
303 if ( $oldid !== 0 ) {
304 # Load the given revision and check whether the page is another one.
305 # In that case, update this instance to reflect the change.
306 if ( $oldid === $this->mPage
->getLatest() ) {
307 $this->mRevisionRecord
= $this->mPage
->getRevisionRecord();
309 $this->mRevisionRecord
= $this->revisionStore
->getRevisionById( $oldid );
310 if ( $this->mRevisionRecord
!== null ) {
311 $revPageId = $this->mRevisionRecord
->getPageId();
312 // Revision title doesn't match the page title given?
313 if ( $this->mPage
->getId() !== $revPageId ) {
314 $this->mPage
= $this->wikiPageFactory
->newFromID( $revPageId );
320 $oldRev = $this->mRevisionRecord
;
321 if ( $request->getRawVal( 'direction' ) === 'next' ) {
324 $nextRev = $this->revisionStore
->getNextRevision( $oldRev );
326 $nextid = $nextRev->getId();
331 $this->mRevisionRecord
= null;
333 $this->mRedirectUrl
= $this->getTitle()->getFullURL( 'redirect=no' );
335 } elseif ( $request->getRawVal( 'direction' ) === 'prev' ) {
338 $prevRev = $this->revisionStore
->getPreviousRevision( $oldRev );
340 $previd = $prevRev->getId();
345 $this->mRevisionRecord
= null;
353 * Fetches the revision to work on.
354 * The revision is loaded from the database. Refer to $this->fetchResult for the revision
355 * or any errors encountered while loading it.
359 * @return RevisionRecord|null
361 public function fetchRevisionRecord() {
362 if ( $this->fetchResult
) {
363 return $this->mRevisionRecord
;
366 $oldid = $this->getOldID();
368 // $this->mRevisionRecord might already be fetched by getOldIDFromRequest()
369 if ( !$this->mRevisionRecord
) {
371 $this->mRevisionRecord
= $this->mPage
->getRevisionRecord();
373 if ( !$this->mRevisionRecord
) {
374 wfDebug( __METHOD__
. " failed to find page data for title " .
375 $this->getTitle()->getPrefixedText() );
377 // Output for this case is done by showMissingArticle().
378 $this->fetchResult
= Status
::newFatal( 'noarticletext' );
382 $this->mRevisionRecord
= $this->revisionStore
->getRevisionById( $oldid );
384 if ( !$this->mRevisionRecord
) {
385 wfDebug( __METHOD__
. " failed to load revision, rev_id $oldid" );
387 $this->fetchResult
= Status
::newFatal( $this->getMissingRevisionMsg( $oldid ) );
393 if ( !$this->mRevisionRecord
->userCan( RevisionRecord
::DELETED_TEXT
, $this->getContext()->getAuthority() ) ) {
394 wfDebug( __METHOD__
. " failed to retrieve content of revision " . $this->mRevisionRecord
->getId() );
396 // Output for this case is done by showDeletedRevisionHeader().
397 // title used in wikilinks, should not contain whitespaces
398 $this->fetchResult
= new Status
;
399 $title = $this->getTitle()->getPrefixedDBkey();
401 if ( $this->mRevisionRecord
->isDeleted( RevisionRecord
::DELETED_RESTRICTED
) ) {
402 $this->fetchResult
->fatal( 'rev-suppressed-text' );
404 $this->fetchResult
->fatal( 'rev-deleted-text-permission', $title );
410 $this->fetchResult
= Status
::newGood( $this->mRevisionRecord
);
411 return $this->mRevisionRecord
;
415 * Returns true if the currently-referenced revision is the current edit
416 * to this page (and it exists).
419 public function isCurrent() {
420 # If no oldid, this is the current version.
421 if ( $this->getOldID() == 0 ) {
425 return $this->mPage
->exists() &&
426 $this->mRevisionRecord
&&
427 $this->mRevisionRecord
->isCurrent();
431 * Use this to fetch the rev ID used on page views
433 * Before fetchRevisionRecord was called, this returns the page's latest revision,
434 * regardless of what getOldID() returns.
436 * @return int Revision ID of last article revision
438 public function getRevIdFetched() {
439 if ( $this->fetchResult
&& $this->fetchResult
->isOK() ) {
440 /** @var RevisionRecord $rev */
441 $rev = $this->fetchResult
->getValue();
442 return $rev->getId();
444 return $this->mPage
->getLatest();
449 * This is the default action of the index.php entry point: just view the
450 * page of the given title.
452 public function view() {
453 $context = $this->getContext();
454 $useFileCache = $context->getConfig()->get( MainConfigNames
::UseFileCache
);
456 # Get variables from query string
457 # As side effect this will load the revision and update the title
458 # in a revision ID is passed in the request, so this should remain
459 # the first call of this method even if $oldid is used way below.
460 $oldid = $this->getOldID();
462 $authority = $context->getAuthority();
463 # Another check in case getOldID() is altering the title
464 $permissionStatus = PermissionStatus
::newEmpty();
466 ->authorizeRead( 'read', $this->getTitle(), $permissionStatus )
468 wfDebug( __METHOD__
. ": denied on secondary read check" );
469 throw new PermissionsError( 'read', $permissionStatus );
472 $outputPage = $context->getOutput();
473 # getOldID() may as well want us to redirect somewhere else
474 if ( $this->mRedirectUrl
) {
475 $outputPage->redirect( $this->mRedirectUrl
);
476 wfDebug( __METHOD__
. ": redirecting due to oldid" );
481 # If we got diff in the query, we want to see a diff page instead of the article.
482 if ( $context->getRequest()->getCheck( 'diff' ) ) {
483 wfDebug( __METHOD__
. ": showing diff page" );
484 $this->showDiffPage();
489 $this->showProtectionIndicator();
491 # Set page title (may be overridden from ParserOutput if title conversion is enabled or DISPLAYTITLE is used)
492 $outputPage->setPageTitle( Parser
::formatPageTitle(
493 str_replace( '_', ' ', $this->getTitle()->getNsText() ),
495 $this->getTitle()->getText()
498 $outputPage->setArticleFlag( true );
499 # Allow frames by default
500 $outputPage->getMetadata()->setPreventClickjacking( false );
502 $parserOptions = $this->getParserOptions();
505 # Allow extensions to vary parser options used for article rendering
506 ( new HookRunner( MediaWikiServices
::getInstance()->getHookContainer() ) )
507 ->onArticleParserOptions( $this, $parserOptions );
508 # Render printable version, use printable version cache
509 if ( $outputPage->isPrintable() ) {
510 $parserOptions->setIsPrintable( true );
511 $poOptions['enableSectionEditLinks'] = false;
512 $this->addMessageBoxStyles( $outputPage );
513 $outputPage->prependHTML(
515 $outputPage->msg( 'printableversion-deprecated-warning' )->escaped()
518 } elseif ( $this->viewIsRenderAction ||
!$this->isCurrent() ||
519 !$authority->probablyCan( 'edit', $this->getTitle() )
521 $poOptions['enableSectionEditLinks'] = false;
524 # Try client and file cache
525 if ( $oldid === 0 && $this->mPage
->checkTouched() ) {
526 # Try to stream the output from file cache
527 if ( $useFileCache && $this->tryFileCache() ) {
528 wfDebug( __METHOD__
. ": done file cache" );
529 # tell wgOut that output is taken care of
530 $outputPage->disable();
531 $this->mPage
->doViewUpdates( $authority, $oldid );
537 $this->showRedirectedFromHeader();
538 $this->showNamespaceHeader();
540 if ( $this->viewIsRenderAction
) {
541 $poOptions +
= [ 'absoluteURLs' => true ];
543 $poOptions +
= [ 'includeDebugInfo' => true ];
547 $this->generateContentOutput( $authority, $parserOptions, $oldid, $outputPage, $poOptions );
548 } catch ( BadRevisionException
$e ) {
550 $this->showViewError( wfMessage( 'badrevision' )->text() );
557 # For the main page, overwrite the <title> element with the con-
558 # tents of 'pagetitle-view-mainpage' instead of the default (if
560 # This message always exists because it is in the i18n files
561 if ( $this->getTitle()->isMainPage() ) {
562 $msg = $context->msg( 'pagetitle-view-mainpage' )->inContentLanguage();
563 if ( !$msg->isDisabled() ) {
564 $outputPage->setHTMLTitle( $msg->text() );
568 # Use adaptive TTLs for CDN so delayed/failed purges are noticed less often.
569 # This could use getTouched(), but that could be scary for major template edits.
570 $outputPage->adaptCdnTTL( $this->mPage
->getTimestamp(), ExpirationAwareness
::TTL_DAY
);
572 $this->showViewFooter();
573 $this->mPage
->doViewUpdates( $authority, $oldid, $this->fetchRevisionRecord() );
575 # Load the postEdit module if the user just saved this revision
576 # See also EditPage::setPostEditCookie
577 $request = $context->getRequest();
578 $cookieKey = EditPage
::POST_EDIT_COOKIE_KEY_PREFIX
. $this->getRevIdFetched();
579 $postEdit = $request->getCookie( $cookieKey );
581 # Clear the cookie. This also prevents caching of the response.
582 $request->response()->clearCookie( $cookieKey );
583 $outputPage->addJsConfigVars( 'wgPostEdit', $postEdit );
584 $outputPage->addModules( 'mediawiki.action.view.postEdit' ); // FIXME: test this
585 if ( $this->getContext()->getConfig()->get( MainConfigNames
::EnableEditRecovery
)
586 && $this->userOptionsLookup
->getOption( $this->getContext()->getUser(), 'editrecovery' )
588 $outputPage->addModules( 'mediawiki.editRecovery.postEdit' );
594 * Show a lock icon above the article body if the page is protected.
596 public function showProtectionIndicator(): void
{
597 $title = $this->getTitle();
598 $context = $this->getContext();
599 $outputPage = $context->getOutput();
601 $protectionIndicatorsAreEnabled = $context->getConfig()
602 ->get( MainConfigNames
::EnableProtectionIndicators
);
604 if ( !$protectionIndicatorsAreEnabled ||
$title->isMainPage() ) {
608 $protection = $this->restrictionStore
->getRestrictions( $title, 'edit' );
610 $cascadeProtection = $this->restrictionStore
->getCascadeProtectionSources( $title )[1];
612 $isCascadeProtected = array_key_exists( 'edit', $cascadeProtection );
614 if ( !$protection && !$isCascadeProtected ) {
618 if ( $isCascadeProtected ) {
619 // Cascade-protected pages are protected at the sysop level. So it
620 // should not matter if we take the protection level of the first
621 // or last page that is being cascaded to the current page.
622 $protectionLevel = $cascadeProtection['edit'][0];
624 $protectionLevel = $protection[0];
627 // Protection levels are stored in the database as plain text, but
628 // they are expected to be valid protection levels. So we should be able to
629 // safely use them. However phan thinks this could be a XSS problem so we
630 // are being paranoid and escaping them once more.
631 $protectionLevel = htmlspecialchars( $protectionLevel );
633 $protectionExpiry = $this->restrictionStore
->getRestrictionExpiry( $title, 'edit' );
634 $formattedProtectionExpiry = $context->getLanguage()
635 ->formatExpiry( $protectionExpiry ??
'' );
637 $protectionMsg = 'protection-indicator-title';
638 if ( $protectionExpiry === 'infinity' ||
!$protectionExpiry ) {
639 $protectionMsg .= '-infinity';
642 // Potential values: 'protection-sysop', 'protection-autoconfirmed',
643 // 'protection-sysop-cascade' etc.
644 // If the wiki has more protection levels, the additional ids that get
645 // added take the form 'protection-<protectionLevel>' and
646 // 'protection-<protectionLevel>-cascade'.
647 $protectionIndicatorId = 'protection-' . $protectionLevel;
648 $protectionIndicatorId .= ( $isCascadeProtected ?
'-cascade' : '' );
650 // Messages 'protection-indicator-title', 'protection-indicator-title-infinity'
651 $protectionMsg = $outputPage->msg( $protectionMsg, $protectionLevel, $formattedProtectionExpiry )->text();
653 // Use a trick similar to the one used in Action::addHelpLink() to allow wikis
654 // to customize where the help link points to.
655 $protectionHelpLink = $outputPage->msg( $protectionIndicatorId . '-helppage' );
656 if ( $protectionHelpLink->isDisabled() ) {
657 $protectionHelpLink = 'https://mediawiki.org/wiki/Special:MyLanguage/Help:Protection';
659 $protectionHelpLink = $protectionHelpLink->text();
662 $outputPage->setIndicators( [
663 $protectionIndicatorId => Html
::rawElement( 'a', [
664 'class' => 'mw-protection-indicator-icon--lock',
665 'title' => $protectionMsg,
666 'href' => $protectionHelpLink
668 // Screen reader-only text describing the same thing as
669 // was mentioned in the title attribute.
670 Html
::element( 'span', [], $protectionMsg ) )
673 $outputPage->addModuleStyles( 'mediawiki.protectionIndicators.styles' );
677 * Determines the desired ParserOutput and passes it to $outputPage.
679 * @param Authority $performer
680 * @param ParserOptions $parserOptions
682 * @param OutputPage $outputPage
683 * @param array $textOptions
685 * @return bool True if further processing like footer generation should be applied,
686 * false to skip further processing.
688 private function generateContentOutput(
689 Authority
$performer,
690 ParserOptions
$parserOptions,
692 OutputPage
$outputPage,
695 # Should the parser cache be used?
696 $useParserCache = true;
698 $parserOutputAccess = MediaWikiServices
::getInstance()->getParserOutputAccess();
700 // NOTE: $outputDone and $useParserCache may be changed by the hook
701 $this->getHookRunner()->onArticleViewHeader( $this, $outputDone, $useParserCache );
703 if ( $outputDone instanceof ParserOutput
) {
704 $pOutput = $outputDone;
708 $this->doOutputMetaData( $pOutput, $outputPage );
713 // Early abort if the page doesn't exist
714 if ( !$this->mPage
->exists() ) {
715 wfDebug( __METHOD__
. ": showing missing article" );
716 $this->showMissingArticle();
717 $this->mPage
->doViewUpdates( $performer );
718 return false; // skip all further output to OutputPage
721 // Try the latest parser cache
722 // NOTE: try latest-revision cache first to avoid loading revision.
723 if ( $useParserCache && !$oldid ) {
724 $pOutput = $parserOutputAccess->getCachedParserOutput(
728 ParserOutputAccess
::OPT_NO_AUDIENCE_CHECK
// we already checked
732 $this->doOutputFromParserCache( $pOutput, $outputPage, $textOptions );
733 $this->doOutputMetaData( $pOutput, $outputPage );
738 $rev = $this->fetchRevisionRecord();
739 if ( !$this->fetchResult
->isOK() ) {
740 $this->showViewError( $this->fetchResult
->getWikiText(
741 false, false, $this->getContext()->getLanguage()
746 # Are we looking at an old revision
748 $this->setOldSubtitle( $oldid );
750 if ( !$this->showDeletedRevisionHeader() ) {
751 wfDebug( __METHOD__
. ": cannot view deleted revision" );
752 return false; // skip all further output to OutputPage
755 // Try the old revision parser cache
756 // NOTE: Repeating cache check for old revision to avoid fetching $rev
757 // before it's absolutely necessary.
758 if ( $useParserCache ) {
759 $pOutput = $parserOutputAccess->getCachedParserOutput(
763 ParserOutputAccess
::OPT_NO_AUDIENCE_CHECK
// we already checked in fetchRevisionRecord
767 $this->doOutputFromParserCache( $pOutput, $outputPage, $textOptions );
768 $this->doOutputMetaData( $pOutput, $outputPage );
774 # Ensure that UI elements requiring revision ID have
775 # the correct version information. (This may be overwritten after creation of ParserOutput)
776 $outputPage->setRevisionId( $this->getRevIdFetched() );
777 $outputPage->setRevisionIsCurrent( $rev->isCurrent() );
778 # Preload timestamp to avoid a DB hit
779 $outputPage->setRevisionTimestamp( $rev->getTimestamp() );
781 # Pages containing custom CSS or JavaScript get special treatment
782 if ( $this->getTitle()->isSiteConfigPage() ||
$this->getTitle()->isUserConfigPage() ) {
783 $dir = $this->getContext()->getLanguage()->getDir();
784 $lang = $this->getContext()->getLanguage()->getHtmlCode();
786 $outputPage->wrapWikiMsg(
787 "<div id='mw-clearyourcache' lang='$lang' dir='$dir' class='mw-content-$dir'>\n$1\n</div>",
790 $outputPage->addModuleStyles( 'mediawiki.action.styles' );
791 } elseif ( !$this->getHookRunner()->onArticleRevisionViewCustom(
797 // NOTE: sync with hooks called in DifferenceEngine::renderNewRevision()
798 // Allow extensions do their own custom view for certain pages
799 $this->doOutputMetaData( $pOutput, $outputPage );
803 # Run the parse, protected by a pool counter
804 wfDebug( __METHOD__
. ": doing uncached parse" );
808 // we already checked the cache in case 2, don't check again.
809 $opt |
= ParserOutputAccess
::OPT_NO_CHECK_CACHE
;
811 // we already checked in fetchRevisionRecord()
812 $opt |
= ParserOutputAccess
::OPT_NO_AUDIENCE_CHECK
;
814 // enable stampede protection and allow stale content
815 $opt |
= ParserOutputAccess
::OPT_FOR_ARTICLE_VIEW
;
817 // Attempt to trigger WikiPage::triggerOpportunisticLinksUpdate
818 // Ideally this should not be the responsibility of the ParserCache to control this.
819 // See https://phabricator.wikimedia.org/T329842#8816557 for more context.
820 $opt |
= ParserOutputAccess
::OPT_LINKS_UPDATE
;
822 if ( !$rev->getId() ||
!$useParserCache ) {
823 // fake revision or uncacheable options
824 $opt |
= ParserOutputAccess
::OPT_NO_CACHE
;
827 $renderStatus = $parserOutputAccess->getParserOutput(
834 // T327164: If parsoid cache warming is enabled, we want to ensure that the page
835 // the user is currently looking at has a cached parsoid rendering, in case they
836 // open visual editor. The cache entry would typically be missing if it has expired
837 // from the cache or it was invalidated by RefreshLinksJob. When "traditional"
838 // parser output has been invalidated by RefreshLinksJob, we will render it on
839 // the fly when a user requests the page, and thereby populate the cache again,
840 // per the code above.
841 // The code below is intended to do the same for parsoid output, but asynchronously
842 // in a job, so the user does not have to wait.
843 // Note that we get here if the traditional parser output was missing from the cache.
844 // We do not check if the parsoid output is present in the cache, because that check
845 // takes time. The assumption is that if we have traditional parser output
846 // cached, we probably also have parsoid output cached.
847 // So we leave it to ParsoidCachePrewarmJob to determine whether or not parsing is
849 if ( $oldid === 0 ||
$oldid === $this->getPage()->getLatest() ) {
850 $parsoidCacheWarmingEnabled = $this->getContext()->getConfig()
851 ->get( MainConfigNames
::ParsoidCacheConfig
)['WarmParsoidParserCache'];
853 if ( $parsoidCacheWarmingEnabled ) {
854 $parsoidJobSpec = ParsoidCachePrewarmJob
::newSpec(
856 $this->getPage()->toPageRecord(),
857 [ 'causeAction' => 'view' ]
859 $this->jobQueueGroup
->lazyPush( $parsoidJobSpec );
863 $this->doOutputFromRenderStatus(
870 if ( !$renderStatus->isOK() ) {
874 $pOutput = $renderStatus->getValue();
875 $this->doOutputMetaData( $pOutput, $outputPage );
880 * @param ?ParserOutput $pOutput
881 * @param OutputPage $outputPage
883 private function doOutputMetaData( ?ParserOutput
$pOutput, OutputPage
$outputPage ) {
884 # Adjust title for main page & pages with displaytitle
886 $this->adjustDisplayTitle( $pOutput );
888 // It would be nice to automatically set this during the first call
889 // to OutputPage::addParserOutputMetadata, but we can't because doing
890 // so would break non-pageview actions where OutputPage::getContLangForJS
891 // has different requirements.
892 $pageLang = $pOutput->getLanguage();
894 $outputPage->setContentLangForJS( $pageLang );
898 # Check for any __NOINDEX__ tags on the page using $pOutput
899 $policy = $this->getRobotPolicy( 'view', $pOutput ?
: null );
900 $outputPage->getMetadata()->setIndexPolicy( $policy['index'] );
901 $outputPage->setFollowPolicy( $policy['follow'] ); // FIXME: test this
903 $this->mParserOutput
= $pOutput;
907 * @param ParserOutput $pOutput
908 * @param OutputPage $outputPage
909 * @param array $textOptions
911 private function doOutputFromParserCache(
912 ParserOutput
$pOutput,
913 OutputPage
$outputPage,
916 # Ensure that UI elements requiring revision ID have
917 # the correct version information.
918 $oldid = $pOutput->getCacheRevisionId() ??
$this->getRevIdFetched();
919 $outputPage->setRevisionId( $oldid );
920 $outputPage->setRevisionIsCurrent( $oldid === $this->mPage
->getLatest() );
921 $outputPage->addParserOutput( $pOutput, $textOptions );
922 # Preload timestamp to avoid a DB hit
923 $cachedTimestamp = $pOutput->getRevisionTimestamp();
924 if ( $cachedTimestamp !== null ) {
925 $outputPage->setRevisionTimestamp( $cachedTimestamp );
926 $this->mPage
->setTimestamp( $cachedTimestamp );
931 * @param RevisionRecord $rev
932 * @param Status $renderStatus
933 * @param OutputPage $outputPage
934 * @param array $textOptions
936 private function doOutputFromRenderStatus(
938 Status
$renderStatus,
939 OutputPage
$outputPage,
942 $context = $this->getContext();
943 if ( !$renderStatus->isOK() ) {
944 $this->showViewError( $renderStatus->getWikiText(
945 false, 'view-pool-error', $context->getLanguage()
950 $pOutput = $renderStatus->getValue();
952 // Cache stale ParserOutput object with a short expiry
953 if ( $renderStatus->hasMessage( 'view-pool-dirty-output' ) ) {
954 $outputPage->lowerCdnMaxage( $context->getConfig()->get( MainConfigNames
::CdnMaxageStale
) );
955 $outputPage->setLastModified( $pOutput->getCacheTime() );
956 $staleReason = $renderStatus->hasMessage( 'view-pool-contention' )
957 ?
$context->msg( 'view-pool-contention' )->escaped()
958 : $context->msg( 'view-pool-timeout' )->escaped();
959 $outputPage->addHTML( "<!-- parser cache is expired, " .
960 "sending anyway due to $staleReason-->\n" );
962 // Ensure OutputPage knowns the id from the dirty cache, but keep the current flag (T341013)
963 $cachedId = $pOutput->getCacheRevisionId();
964 if ( $cachedId !== null ) {
965 $outputPage->setRevisionId( $cachedId );
966 $outputPage->setRevisionTimestamp( $pOutput->getTimestamp() );
970 $outputPage->addParserOutput( $pOutput, $textOptions );
972 if ( $this->getRevisionRedirectTarget( $rev ) ) {
973 $outputPage->addSubtitle( "<span id=\"redirectsub\">" .
974 $context->msg( 'redirectpagesub' )->parse() . "</span>" );
979 * @param RevisionRecord $revision
982 private function getRevisionRedirectTarget( RevisionRecord
$revision ) {
983 // TODO: find a *good* place for the code that determines the redirect target for
985 // NOTE: Use main slot content. Compare code in DerivedPageDataUpdater::revisionIsRedirect.
986 $content = $revision->getContent( SlotRecord
::MAIN
);
987 return $content ?
$content->getRedirectTarget() : null;
991 * Adjust title for pages with displaytitle, -{T|}- or language conversion
992 * @param ParserOutput $pOutput
994 public function adjustDisplayTitle( ParserOutput
$pOutput ) {
995 $out = $this->getContext()->getOutput();
997 # Adjust the title if it was set by displaytitle, -{T|}- or language conversion
998 $titleText = $pOutput->getTitleText();
999 if ( strval( $titleText ) !== '' ) {
1000 $out->setPageTitle( $titleText );
1001 $out->setDisplayTitle( $titleText );
1006 * Show a diff page according to current request variables. For use within
1007 * Article::view() only, other callers should use the DifferenceEngine class.
1009 protected function showDiffPage() {
1010 $context = $this->getContext();
1011 $outputPage = $context->getOutput();
1012 $outputPage->addBodyClasses( 'mw-article-diff' );
1013 $request = $context->getRequest();
1014 $diff = $request->getVal( 'diff' );
1015 $rcid = $request->getInt( 'rcid' );
1016 $purge = $request->getRawVal( 'action' ) === 'purge';
1017 $unhide = $request->getInt( 'unhide' ) === 1;
1018 $oldid = $this->getOldID();
1020 $rev = $this->fetchRevisionRecord();
1023 // T213621: $rev maybe null due to either lack of permission to view the
1024 // revision or actually not existing. So let's try loading it from the id
1025 $rev = $this->revisionStore
->getRevisionById( $oldid );
1027 // Revision exists but $user lacks permission to diff it.
1029 // The $rev will later be used to create standard diff elements however.
1031 $outputPage->setPageTitleMsg( $context->msg( 'errorpagetitle' ) );
1032 $msg = $context->msg( 'difference-missing-revision' )
1036 $outputPage->addHTML( $msg );
1041 $services = MediaWikiServices
::getInstance();
1043 $contentHandler = $services
1044 ->getContentHandlerFactory()
1045 ->getContentHandler(
1046 $rev->getMainContentModel()
1048 $de = $contentHandler->createDifferenceEngine(
1057 $diffType = $request->getVal( 'diff-type' );
1059 if ( $diffType === null ) {
1060 $diffType = $this->userOptionsLookup
1061 ->getOption( $context->getUser(), 'diff-type' );
1063 $de->setExtraQueryParams( [ 'diff-type' => $diffType ] );
1066 $de->setSlotDiffOptions( [
1067 'diff-type' => $diffType,
1068 'expand-url' => $this->viewIsRenderAction
,
1069 'inline-toggle' => true,
1071 $de->showDiffPage( $this->isDiffOnlyView() );
1073 // Run view updates for the newer revision being diffed (and shown
1074 // below the diff if not diffOnly).
1075 [ , $new ] = $de->mapDiffPrevNext( $oldid, $diff );
1076 // New can be false, convert it to 0 - this conveniently means the latest revision
1077 $this->mPage
->doViewUpdates( $context->getAuthority(), (int)$new );
1079 // Add link to help page; see T321569
1080 $context->getOutput()->addHelpLink( 'Help:Diff' );
1083 protected function isDiffOnlyView() {
1084 return $this->getContext()->getRequest()->getBool(
1086 $this->userOptionsLookup
->getBoolOption( $this->getContext()->getUser(), 'diffonly' )
1091 * Get the robot policy to be used for the current view
1092 * @param string $action The action= GET parameter
1093 * @param ParserOutput|null $pOutput
1094 * @return string[] The policy that should be set
1095 * @todo actions other than 'view'
1097 public function getRobotPolicy( $action, ?ParserOutput
$pOutput = null ) {
1098 $context = $this->getContext();
1099 $mainConfig = $context->getConfig();
1100 $articleRobotPolicies = $mainConfig->get( MainConfigNames
::ArticleRobotPolicies
);
1101 $namespaceRobotPolicies = $mainConfig->get( MainConfigNames
::NamespaceRobotPolicies
);
1102 $defaultRobotPolicy = $mainConfig->get( MainConfigNames
::DefaultRobotPolicy
);
1103 $title = $this->getTitle();
1104 $ns = $title->getNamespace();
1106 # Don't index user and user talk pages for blocked users (T13443)
1107 if ( $ns === NS_USER ||
$ns === NS_USER_TALK
) {
1108 $specificTarget = null;
1109 $vagueTarget = null;
1110 $titleText = $title->getText();
1111 if ( IPUtils
::isValid( $titleText ) ) {
1112 $vagueTarget = $titleText;
1114 $specificTarget = $title->getRootText();
1116 if ( $this->blockStore
->newFromTarget( $specificTarget, $vagueTarget ) instanceof DatabaseBlock
) {
1118 'index' => 'noindex',
1119 'follow' => 'nofollow'
1124 if ( $this->mPage
->getId() === 0 ||
$this->getOldID() ) {
1125 # Non-articles (special pages etc), and old revisions
1127 'index' => 'noindex',
1128 'follow' => 'nofollow'
1130 } elseif ( $context->getOutput()->isPrintable() ) {
1131 # Discourage indexing of printable versions, but encourage following
1133 'index' => 'noindex',
1134 'follow' => 'follow'
1136 } elseif ( $context->getRequest()->getInt( 'curid' ) ) {
1137 # For ?curid=x urls, disallow indexing
1139 'index' => 'noindex',
1140 'follow' => 'follow'
1144 # Otherwise, construct the policy based on the various config variables.
1145 $policy = self
::formatRobotPolicy( $defaultRobotPolicy );
1147 if ( isset( $namespaceRobotPolicies[$ns] ) ) {
1148 # Honour customised robot policies for this namespace
1149 $policy = array_merge(
1151 self
::formatRobotPolicy( $namespaceRobotPolicies[$ns] )
1154 if ( $title->canUseNoindex() && $pOutput && $pOutput->getIndexPolicy() ) {
1155 # __INDEX__ and __NOINDEX__ magic words, if allowed. Incorporates
1156 # a final check that we have really got the parser output.
1157 $policy = array_merge(
1159 [ 'index' => $pOutput->getIndexPolicy() ]
1163 if ( isset( $articleRobotPolicies[$title->getPrefixedText()] ) ) {
1164 # (T16900) site config can override user-defined __INDEX__ or __NOINDEX__
1165 $policy = array_merge(
1167 self
::formatRobotPolicy( $articleRobotPolicies[$title->getPrefixedText()] )
1175 * Converts a String robot policy into an associative array, to allow
1176 * merging of several policies using array_merge().
1177 * @param array|string $policy Returns empty array on null/false/'', transparent
1178 * to already-converted arrays, converts string.
1179 * @return array 'index' => \<indexpolicy\>, 'follow' => \<followpolicy\>
1181 public static function formatRobotPolicy( $policy ) {
1182 if ( is_array( $policy ) ) {
1184 } elseif ( !$policy ) {
1189 foreach ( explode( ',', $policy ) as $var ) {
1190 $var = trim( $var );
1191 if ( $var === 'index' ||
$var === 'noindex' ) {
1192 $arr['index'] = $var;
1193 } elseif ( $var === 'follow' ||
$var === 'nofollow' ) {
1194 $arr['follow'] = $var;
1202 * If this request is a redirect view, send "redirected from" subtitle to
1203 * the output. Returns true if the header was needed, false if this is not
1204 * a redirect view. Handles both local and remote redirects.
1208 public function showRedirectedFromHeader() {
1209 $context = $this->getContext();
1210 $redirectSources = $context->getConfig()->get( MainConfigNames
::RedirectSources
);
1211 $outputPage = $context->getOutput();
1212 $request = $context->getRequest();
1213 $rdfrom = $request->getVal( 'rdfrom' );
1215 // Construct a URL for the current page view, but with the target title
1216 $query = $request->getQueryValues();
1217 unset( $query['rdfrom'] );
1218 unset( $query['title'] );
1219 if ( $this->getTitle()->isRedirect() ) {
1220 // Prevent double redirects
1221 $query['redirect'] = 'no';
1223 $redirectTargetUrl = $this->getTitle()->getLinkURL( $query );
1225 if ( $this->mRedirectedFrom
) {
1226 // This is an internally redirected page view.
1227 // We'll need a backlink to the source page for navigation.
1228 if ( $this->getHookRunner()->onArticleViewRedirect( $this ) ) {
1229 $redir = $this->linkRenderer
->makeKnownLink(
1230 $this->mRedirectedFrom
,
1233 [ 'redirect' => 'no' ]
1236 $outputPage->addSubtitle( "<span class=\"mw-redirectedfrom\">" .
1237 $context->msg( 'redirectedfrom' )->rawParams( $redir )->parse()
1240 // Add the script to update the displayed URL and
1241 // set the fragment if one was specified in the redirect
1242 $outputPage->addJsConfigVars( [
1243 'wgInternalRedirectTargetUrl' => $redirectTargetUrl,
1245 $outputPage->addModules( 'mediawiki.action.view.redirect' );
1247 // Add a <link rel="canonical"> tag
1248 $outputPage->setCanonicalUrl( $this->getTitle()->getCanonicalURL() );
1250 // Tell the output object that the user arrived at this article through a redirect
1251 $outputPage->setRedirectedFrom( $this->mRedirectedFrom
);
1255 } elseif ( $rdfrom ) {
1256 // This is an externally redirected view, from some other wiki.
1257 // If it was reported from a trusted site, supply a backlink.
1258 if ( $redirectSources && preg_match( $redirectSources, $rdfrom ) ) {
1259 $redir = $this->linkRenderer
->makeExternalLink( $rdfrom, $rdfrom, $this->getTitle() );
1260 $outputPage->addSubtitle( "<span class=\"mw-redirectedfrom\">" .
1261 $context->msg( 'redirectedfrom' )->rawParams( $redir )->parse()
1264 // Add the script to update the displayed URL
1265 $outputPage->addJsConfigVars( [
1266 'wgInternalRedirectTargetUrl' => $redirectTargetUrl,
1268 $outputPage->addModules( 'mediawiki.action.view.redirect' );
1278 * Show a header specific to the namespace currently being viewed, like
1279 * [[MediaWiki:Talkpagetext]]. For Article::view().
1281 public function showNamespaceHeader() {
1282 if ( $this->getTitle()->isTalkPage() && !$this->getContext()->msg( 'talkpageheader' )->isDisabled() ) {
1283 $this->getContext()->getOutput()->wrapWikiMsg(
1284 "<div class=\"mw-talkpageheader\">\n$1\n</div>",
1285 [ 'talkpageheader' ]
1291 * Show the footer section of an ordinary page view
1293 public function showViewFooter() {
1294 # check if we're displaying a [[User talk:x.x.x.x]] anonymous talk page
1295 if ( $this->getTitle()->getNamespace() === NS_USER_TALK
1296 && IPUtils
::isValid( $this->getTitle()->getText() )
1298 $this->getContext()->getOutput()->addWikiMsg( 'anontalkpagetext' );
1301 // Show a footer allowing the user to patrol the shown revision or page if possible
1302 $patrolFooterShown = $this->showPatrolFooter();
1304 $this->getHookRunner()->onArticleViewFooter( $this, $patrolFooterShown );
1308 * If patrol is possible, output a patrol UI box. This is called from the
1309 * footer section of ordinary page views. If patrol is not possible or not
1310 * desired, does nothing.
1312 * Side effect: When the patrol link is build, this method will call
1313 * OutputPage::setPreventClickjacking(true) and load a JS module.
1317 public function showPatrolFooter() {
1318 $context = $this->getContext();
1319 $mainConfig = $context->getConfig();
1320 $useNPPatrol = $mainConfig->get( MainConfigNames
::UseNPPatrol
);
1321 $useRCPatrol = $mainConfig->get( MainConfigNames
::UseRCPatrol
);
1322 $useFilePatrol = $mainConfig->get( MainConfigNames
::UseFilePatrol
);
1323 // Allow hooks to decide whether to not output this at all
1324 if ( !$this->getHookRunner()->onArticleShowPatrolFooter( $this ) ) {
1328 $outputPage = $context->getOutput();
1329 $user = $context->getUser();
1330 $title = $this->getTitle();
1333 if ( !$context->getAuthority()->probablyCan( 'patrol', $title )
1334 ||
!( $useRCPatrol ||
$useNPPatrol
1335 ||
( $useFilePatrol && $title->inNamespace( NS_FILE
) ) )
1337 // Patrolling is disabled or the user isn't allowed to
1341 if ( $this->mRevisionRecord
1342 && !RecentChange
::isInRCLifespan( $this->mRevisionRecord
->getTimestamp(), 21600 )
1344 // The current revision is already older than what could be in the RC table
1345 // 6h tolerance because the RC might not be cleaned out regularly
1349 // Check for cached results
1350 $cache = MediaWikiServices
::getInstance()->getMainWANObjectCache();
1351 $key = $cache->makeKey( 'unpatrollable-page', $title->getArticleID() );
1352 if ( $cache->get( $key ) ) {
1356 $dbr = $this->dbProvider
->getReplicaDatabase();
1357 $oldestRevisionRow = $dbr->newSelectQueryBuilder()
1358 ->select( [ 'rev_id', 'rev_timestamp' ] )
1359 ->from( 'revision' )
1360 ->where( [ 'rev_page' => $title->getArticleID() ] )
1361 ->orderBy( [ 'rev_timestamp', 'rev_id' ] )
1362 ->caller( __METHOD__
)->fetchRow();
1363 $oldestRevisionTimestamp = $oldestRevisionRow ?
$oldestRevisionRow->rev_timestamp
: false;
1365 // New page patrol: Get the timestamp of the oldest revision which
1366 // the revision table holds for the given page. Then we look
1367 // whether it's within the RC lifespan and if it is, we try
1368 // to get the recentchanges row belonging to that entry.
1369 $recentPageCreation = false;
1370 if ( $oldestRevisionTimestamp
1371 && RecentChange
::isInRCLifespan( $oldestRevisionTimestamp, 21600 )
1373 // 6h tolerance because the RC might not be cleaned out regularly
1374 $recentPageCreation = true;
1375 $rc = RecentChange
::newFromConds(
1377 'rc_this_oldid' => intval( $oldestRevisionRow->rev_id
),
1378 // Avoid selecting a categorization entry
1379 'rc_type' => RC_NEW
,
1384 // Use generic patrol message for new pages
1385 $markPatrolledMsg = $context->msg( 'markaspatrolledtext' );
1389 // File patrol: Get the timestamp of the latest upload for this page,
1390 // check whether it is within the RC lifespan and if it is, we try
1391 // to get the recentchanges row belonging to that entry
1392 // (with rc_type = RC_LOG, rc_log_type = upload).
1393 $recentFileUpload = false;
1394 if ( ( !$rc ||
$rc->getAttribute( 'rc_patrolled' ) ) && $useFilePatrol
1395 && $title->getNamespace() === NS_FILE
) {
1396 // Retrieve timestamp from the current file (lastest upload)
1397 $newestUploadTimestamp = $dbr->newSelectQueryBuilder()
1398 ->select( 'img_timestamp' )
1400 ->where( [ 'img_name' => $title->getDBkey() ] )
1401 ->caller( __METHOD__
)->fetchField();
1402 if ( $newestUploadTimestamp
1403 && RecentChange
::isInRCLifespan( $newestUploadTimestamp, 21600 )
1405 // 6h tolerance because the RC might not be cleaned out regularly
1406 $recentFileUpload = true;
1407 $rc = RecentChange
::newFromConds(
1409 'rc_type' => RC_LOG
,
1410 'rc_log_type' => 'upload',
1411 'rc_timestamp' => $newestUploadTimestamp,
1412 'rc_namespace' => NS_FILE
,
1413 'rc_cur_id' => $title->getArticleID()
1418 // Use patrol message specific to files
1419 $markPatrolledMsg = $context->msg( 'markaspatrolledtext-file' );
1424 if ( !$recentPageCreation && !$recentFileUpload ) {
1425 // Page creation and latest upload (for files) is too old to be in RC
1427 // We definitely can't patrol so cache the information
1428 // When a new file version is uploaded, the cache is cleared
1429 $cache->set( $key, '1' );
1435 // Don't cache: This can be hit if the page gets accessed very fast after
1436 // its creation / latest upload or in case we have high replica DB lag. In case
1437 // the revision is too old, we will already return above.
1441 if ( $rc->getAttribute( 'rc_patrolled' ) ) {
1442 // Patrolled RC entry around
1444 // Cache the information we gathered above in case we can't patrol
1445 // Don't cache in case we can patrol as this could change
1446 $cache->set( $key, '1' );
1451 if ( $rc->getPerformerIdentity()->equals( $user ) ) {
1452 // Don't show a patrol link for own creations/uploads. If the user could
1453 // patrol them, they already would be patrolled
1457 $outputPage->getMetadata()->setPreventClickjacking( true );
1458 $outputPage->addModules( 'mediawiki.misc-authed-curate' );
1460 $link = $this->linkRenderer
->makeKnownLink(
1462 new HtmlArmor( '<button class="cdx-button cdx-button--action-progressive">'
1463 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable $markPatrolledMsg is always set
1464 . $markPatrolledMsg->escaped() . '</button>' ),
1467 'action' => 'markpatrolled',
1468 'rcid' => $rc->getAttribute( 'rc_id' ),
1472 $outputPage->addModuleStyles( 'mediawiki.action.styles' );
1473 $outputPage->addHTML( "<div class='patrollink' data-mw='interface'>$link</div>" );
1479 * Purge the cache used to check if it is worth showing the patrol footer
1480 * For example, it is done during re-uploads when file patrol is used.
1481 * @param int $articleID ID of the article to purge
1484 public static function purgePatrolFooterCache( $articleID ) {
1485 $cache = MediaWikiServices
::getInstance()->getMainWANObjectCache();
1486 $cache->delete( $cache->makeKey( 'unpatrollable-page', $articleID ) );
1490 * Show the error text for a missing article. For articles in the MediaWiki
1491 * namespace, show the default message text. To be called from Article::view().
1493 public function showMissingArticle() {
1494 $context = $this->getContext();
1495 $send404Code = $context->getConfig()->get( MainConfigNames
::Send404Code
);
1497 $outputPage = $context->getOutput();
1498 // Whether the page is a root user page of an existing user (but not a subpage)
1499 $validUserPage = false;
1501 $title = $this->getTitle();
1503 $services = MediaWikiServices
::getInstance();
1505 $contextUser = $context->getUser();
1507 # Show info in user (talk) namespace. Does the user exist? Is he blocked?
1508 if ( $title->getNamespace() === NS_USER
1509 ||
$title->getNamespace() === NS_USER_TALK
1511 $rootPart = $title->getRootText();
1512 $userFactory = $services->getUserFactory();
1513 $user = $userFactory->newFromNameOrIp( $rootPart );
1515 $block = $this->blockStore
->newFromTarget( $user, $user );
1517 if ( $user && $user->isRegistered() && $user->isHidden() &&
1518 !$context->getAuthority()->isAllowed( 'hideuser' )
1520 // T120883 if the user is hidden and the viewer cannot see hidden
1521 // users, pretend like it does not exist at all.
1525 if ( !( $user && $user->isRegistered() ) && !$this->userNameUtils
->isIP( $rootPart ) ) {
1526 $this->addMessageBoxStyles( $outputPage );
1527 // User does not exist
1528 $outputPage->addHTML( Html
::warningBox(
1529 $context->msg( 'userpage-userdoesnotexist-view', wfEscapeWikiText( $rootPart ) )->parse(),
1530 'mw-userpage-userdoesnotexist'
1533 // Show renameuser log extract
1534 LogEventsList
::showLogExtract(
1537 Title
::makeTitleSafe( NS_USER
, $rootPart ),
1541 'showIfEmpty' => false,
1542 'msgKey' => [ 'renameuser-renamed-notice', $title->getBaseText() ]
1546 $user && $block !== null &&
1547 $block->getType() != DatabaseBlock
::TYPE_AUTO
&&
1549 $block->isSitewide() ||
1550 $services->getPermissionManager()->isBlockedFrom( $user, $title, true )
1553 // Show log extract if the user is sitewide blocked or is partially
1554 // blocked and not allowed to edit their user page or user talk page
1555 LogEventsList
::showLogExtract(
1558 $services->getNamespaceInfo()->getCanonicalName( NS_USER
) . ':' .
1559 $block->getTargetName(),
1563 'showIfEmpty' => false,
1565 'blocked-notice-logextract',
1566 $user->getName() # Support GENDER in notice
1570 $validUserPage = !$title->isSubpage();
1572 $validUserPage = !$title->isSubpage();
1576 $this->getHookRunner()->onShowMissingArticle( $this );
1578 # Show delete and move logs if there were any such events.
1579 # The logging query can DOS the site when bots/crawlers cause 404 floods,
1580 # so be careful showing this. 404 pages must be cheap as they are hard to cache.
1581 $dbCache = MediaWikiServices
::getInstance()->getMainObjectStash();
1582 $key = $dbCache->makeKey( 'page-recent-delete', md5( $title->getPrefixedText() ) );
1583 $isRegistered = $contextUser->isRegistered();
1584 $sessionExists = $context->getRequest()->getSession()->isPersistent();
1586 if ( $isRegistered ||
$dbCache->get( $key ) ||
$sessionExists ) {
1587 $logTypes = [ 'delete', 'move', 'protect', 'merge' ];
1589 $dbr = $this->dbProvider
->getReplicaDatabase();
1591 $conds = [ $dbr->expr( 'log_action', '!=', 'revision' ) ];
1592 // Give extensions a chance to hide their (unrelated) log entries
1593 $this->getHookRunner()->onArticle__MissingArticleConditions( $conds, $logTypes );
1594 LogEventsList
::showLogExtract(
1602 'showIfEmpty' => false,
1603 'msgKey' => [ $isRegistered ||
$sessionExists
1604 ?
'moveddeleted-notice'
1605 : 'moveddeleted-notice-recent'
1611 if ( !$this->mPage
->hasViewableContent() && $send404Code && !$validUserPage ) {
1612 // If there's no backing content, send a 404 Not Found
1613 // for better machine handling of broken links.
1614 $context->getRequest()->response()->statusHeader( 404 );
1617 // Also apply the robot policy for nonexisting pages (even if a 404 was used)
1618 $policy = $this->getRobotPolicy( 'view' );
1619 $outputPage->getMetadata()->setIndexPolicy( $policy['index'] );
1620 $outputPage->setFollowPolicy( $policy['follow'] );
1622 $hookResult = $this->getHookRunner()->onBeforeDisplayNoArticleText( $this );
1624 if ( !$hookResult ) {
1628 # Show error message
1629 $oldid = $this->getOldID();
1630 if ( !$oldid && $title->getNamespace() === NS_MEDIAWIKI
&& $title->hasSourceText() ) {
1631 $text = $this->getTitle()->getDefaultMessageText() ??
'';
1632 $outputPage->addWikiTextAsContent( $text );
1635 $text = $this->getMissingRevisionMsg( $oldid )->plain();
1636 } elseif ( $context->getAuthority()->probablyCan( 'edit', $title ) ) {
1637 $message = $isRegistered ?
'noarticletext' : 'noarticletextanon';
1638 $text = $context->msg( $message )->plain();
1640 $text = $context->msg( 'noarticletext-nopermission' )->plain();
1643 $dir = $context->getLanguage()->getDir();
1644 $lang = $context->getLanguage()->getHtmlCode();
1645 $outputPage->addWikiTextAsInterface( Xml
::openElement( 'div', [
1646 'class' => "noarticletext mw-content-$dir",
1649 ] ) . "\n$text\n</div>" );
1654 * Show error text for errors generated in Article::view().
1655 * @param string $errortext localized wikitext error message
1657 private function showViewError( string $errortext ) {
1658 $outputPage = $this->getContext()->getOutput();
1659 $outputPage->setPageTitleMsg( $this->getContext()->msg( 'errorpagetitle' ) );
1660 $outputPage->disableClientCache();
1661 $outputPage->setRobotPolicy( 'noindex,nofollow' );
1662 $outputPage->clearHTML();
1663 $this->addMessageBoxStyles( $outputPage );
1664 $outputPage->addHTML( Html
::errorBox( $outputPage->parseAsContent( $errortext ) ) );
1668 * If the revision requested for view is deleted, check permissions.
1669 * Send either an error message or a warning header to the output.
1671 * @return bool True if the view is allowed, false if not.
1673 public function showDeletedRevisionHeader() {
1674 if ( !$this->mRevisionRecord
->isDeleted( RevisionRecord
::DELETED_TEXT
) ) {
1678 $outputPage = $this->getContext()->getOutput();
1679 // Used in wikilinks, should not contain whitespaces
1680 $titleText = $this->getTitle()->getPrefixedDBkey();
1681 $this->addMessageBoxStyles( $outputPage );
1682 // If the user is not allowed to see it...
1683 if ( !$this->mRevisionRecord
->userCan(
1684 RevisionRecord
::DELETED_TEXT
,
1685 $this->getContext()->getAuthority()
1687 $outputPage->addHTML(
1689 $outputPage->msg( 'rev-deleted-text-permission', $titleText )->parse(),
1695 // If the user needs to confirm that they want to see it...
1696 } elseif ( $this->getContext()->getRequest()->getInt( 'unhide' ) !== 1 ) {
1697 # Give explanation and add a link to view the revision...
1698 $oldid = intval( $this->getOldID() );
1699 $link = $this->getTitle()->getFullURL( "oldid={$oldid}&unhide=1" );
1700 $msg = $this->mRevisionRecord
->isDeleted( RevisionRecord
::DELETED_RESTRICTED
) ?
1701 'rev-suppressed-text-unhide' : 'rev-deleted-text-unhide';
1702 $outputPage->addHTML(
1704 $outputPage->msg( $msg, $link )->parse(),
1710 // We are allowed to see...
1712 $msg = $this->mRevisionRecord
->isDeleted( RevisionRecord
::DELETED_RESTRICTED
)
1713 ?
[ 'rev-suppressed-text-view', $titleText ]
1714 : [ 'rev-deleted-text-view', $titleText ];
1715 $outputPage->addHTML(
1717 $outputPage->msg( $msg[0], $msg[1] )->parse(),
1727 * @param OutputPage $outputPage
1729 private function addMessageBoxStyles( OutputPage
$outputPage ) {
1730 $outputPage->addModuleStyles( [
1731 'mediawiki.codex.messagebox.styles',
1736 * Generate the navigation links when browsing through an article revisions
1737 * It shows the information as:
1738 * Revision as of \<date\>; view current revision
1739 * \<- Previous version | Next Version -\>
1741 * @param int $oldid Revision ID of this article revision
1743 public function setOldSubtitle( $oldid = 0 ) {
1744 if ( !$this->getHookRunner()->onDisplayOldSubtitle( $this, $oldid ) ) {
1748 $context = $this->getContext();
1749 $unhide = $context->getRequest()->getInt( 'unhide' ) === 1;
1751 # Cascade unhide param in links for easy deletion browsing
1754 $extraParams['unhide'] = 1;
1757 if ( $this->mRevisionRecord
&& $this->mRevisionRecord
->getId() === $oldid ) {
1758 $revisionRecord = $this->mRevisionRecord
;
1760 $revisionRecord = $this->revisionStore
->getRevisionById( $oldid );
1762 if ( !$revisionRecord ) {
1763 throw new LogicException( 'There should be a revision record at this point.' );
1766 $timestamp = $revisionRecord->getTimestamp();
1768 $current = ( $oldid == $this->mPage
->getLatest() );
1769 $language = $context->getLanguage();
1770 $user = $context->getUser();
1772 $td = $language->userTimeAndDate( $timestamp, $user );
1773 $tddate = $language->userDate( $timestamp, $user );
1774 $tdtime = $language->userTime( $timestamp, $user );
1776 # Show user links if allowed to see them. If hidden, then show them only if requested...
1777 $userlinks = Linker
::revUserTools( $revisionRecord, !$unhide );
1779 $infomsg = $current && !$context->msg( 'revision-info-current' )->isDisabled()
1780 ?
'revision-info-current'
1783 $outputPage = $context->getOutput();
1784 $outputPage->addModuleStyles( [
1785 'mediawiki.action.styles',
1786 'mediawiki.interface.helpers.styles'
1789 $revisionUser = $revisionRecord->getUser();
1790 $revisionInfo = "<div id=\"mw-{$infomsg}\">" .
1791 $context->msg( $infomsg, $td )
1792 ->rawParams( $userlinks )
1794 $revisionRecord->getId(),
1797 $revisionUser ?
$revisionUser->getName() : ''
1799 ->rawParams( $this->commentFormatter
->formatRevision(
1809 ?
$context->msg( 'currentrevisionlink' )->escaped()
1810 : $this->linkRenderer
->makeKnownLink(
1812 $context->msg( 'currentrevisionlink' )->text(),
1817 ?
$context->msg( 'diff' )->escaped()
1818 : $this->linkRenderer
->makeKnownLink(
1820 $context->msg( 'diff' )->text(),
1827 $prevExist = (bool)$this->revisionStore
->getPreviousRevision( $revisionRecord );
1828 $prevlink = $prevExist
1829 ?
$this->linkRenderer
->makeKnownLink(
1831 $context->msg( 'previousrevision' )->text(),
1834 'direction' => 'prev',
1838 : $context->msg( 'previousrevision' )->escaped();
1839 $prevdiff = $prevExist
1840 ?
$this->linkRenderer
->makeKnownLink(
1842 $context->msg( 'diff' )->text(),
1849 : $context->msg( 'diff' )->escaped();
1850 $nextlink = $current
1851 ?
$context->msg( 'nextrevision' )->escaped()
1852 : $this->linkRenderer
->makeKnownLink(
1854 $context->msg( 'nextrevision' )->text(),
1857 'direction' => 'next',
1861 $nextdiff = $current
1862 ?
$context->msg( 'diff' )->escaped()
1863 : $this->linkRenderer
->makeKnownLink(
1865 $context->msg( 'diff' )->text(),
1873 $cdel = Linker
::getRevDeleteLink(
1874 $context->getAuthority(),
1878 if ( $cdel !== '' ) {
1882 // the outer div is need for styling the revision info and nav in MobileFrontend
1883 $this->addMessageBoxStyles( $outputPage );
1884 $outputPage->addSubtitle(
1887 "<div id=\"mw-revision-nav\">" . $cdel .
1888 $context->msg( 'revision-nav' )->rawParams(
1889 $prevdiff, $prevlink, $lnk, $curdiff, $nextlink, $nextdiff
1890 )->escaped() . "</div>",
1897 * Return the HTML for the top of a redirect page
1899 * Chances are you should just be using the ParserOutput from
1900 * WikitextContent::getParserOutput instead of calling this for redirects.
1903 * @param Language $lang
1904 * @param Title $target Destination to redirect
1905 * @param bool $forceKnown Should the image be shown as a bluelink regardless of existence?
1906 * @return string Containing HTML with redirect link
1907 * @deprecated since 1.41, use LinkRenderer::makeRedirectHeader() instead
1909 public static function getRedirectHeaderHtml( Language
$lang, Title
$target, $forceKnown = false ) {
1910 wfDeprecated( __METHOD__
, '1.41' );
1911 $linkRenderer = MediaWikiServices
::getInstance()->getLinkRenderer();
1912 return $linkRenderer->makeRedirectHeader( $lang, $target, $forceKnown );
1916 * Adds help link with an icon via page indicators.
1917 * Link target can be overridden by a local message containing a wikilink:
1918 * the message key is: 'namespace-' + namespace number + '-helppage'.
1919 * @param string $to Target MediaWiki.org page title or encoded URL.
1920 * @param bool $overrideBaseUrl Whether $url is a full URL, to avoid MW.o.
1923 public function addHelpLink( $to, $overrideBaseUrl = false ) {
1924 $out = $this->getContext()->getOutput();
1925 $msg = $out->msg( 'namespace-' . $this->getTitle()->getNamespace() . '-helppage' );
1927 if ( !$msg->isDisabled() ) {
1928 $title = Title
::newFromText( $msg->plain() );
1929 if ( $title instanceof Title
) {
1930 $out->addHelpLink( $title->getLocalURL(), true );
1933 $out->addHelpLink( $to, $overrideBaseUrl );
1938 * Handle action=render
1940 public function render() {
1941 $this->getContext()->getRequest()->response()->header( 'X-Robots-Tag: noindex' );
1942 $this->getContext()->getOutput()->setArticleBodyOnly( true );
1943 // We later set 'enableSectionEditLinks=false' based on this; also used by ImagePage
1944 $this->viewIsRenderAction
= true;
1949 * action=protect handler
1951 public function protect() {
1952 $form = new ProtectionForm( $this );
1957 * action=unprotect handler (alias)
1959 public function unprotect() {
1963 /* Caching functions */
1966 * checkLastModified returns true if it has taken care of all
1967 * output to the client that is necessary for this request.
1968 * (that is, it has sent a cached version of the page)
1970 * @return bool True if cached version send, false otherwise
1972 protected function tryFileCache() {
1973 static $called = false;
1976 wfDebug( "Article::tryFileCache(): called twice!?" );
1981 if ( $this->isFileCacheable() ) {
1982 $cache = new HTMLFileCache( $this->getTitle(), 'view' );
1983 if ( $cache->isCacheGood( $this->mPage
->getTouched() ) ) {
1984 wfDebug( "Article::tryFileCache(): about to load file" );
1985 $cache->loadFromFileCache( $this->getContext() );
1988 wfDebug( "Article::tryFileCache(): starting buffer" );
1989 ob_start( [ &$cache, 'saveToFileCache' ] );
1992 wfDebug( "Article::tryFileCache(): not cacheable" );
1999 * Check if the page can be cached
2000 * @param int $mode One of the HTMLFileCache::MODE_* constants (since 1.28)
2003 public function isFileCacheable( $mode = HTMLFileCache
::MODE_NORMAL
) {
2006 if ( HTMLFileCache
::useFileCache( $this->getContext(), $mode ) ) {
2007 $cacheable = $this->mPage
->getId()
2008 && !$this->mRedirectedFrom
&& !$this->getTitle()->isRedirect();
2009 // Extension may have reason to disable file caching on some pages.
2011 $cacheable = $this->getHookRunner()->onIsFileCacheable( $this ) ??
false;
2019 * Lightweight method to get the parser output for a page, checking the parser cache
2020 * and so on. Doesn't consider most of the stuff that Article::view() is forced to
2021 * consider, so it's not appropriate to use there.
2023 * @since 1.16 (r52326) for LiquidThreads
2025 * @param int|null $oldid Revision ID or null
2026 * @param UserIdentity|null $user The relevant user
2027 * @return ParserOutput|false ParserOutput or false if the given revision ID is not found
2029 public function getParserOutput( $oldid = null, ?UserIdentity
$user = null ) {
2030 if ( $user === null ) {
2031 $parserOptions = $this->getParserOptions();
2033 $parserOptions = $this->mPage
->makeParserOptions( $user );
2034 $parserOptions->setRenderReason( 'page-view' );
2037 return $this->mPage
->getParserOutput( $parserOptions, $oldid );
2041 * Get parser options suitable for rendering the primary article wikitext
2042 * @return ParserOptions
2044 public function getParserOptions() {
2045 $parserOptions = $this->mPage
->makeParserOptions( $this->getContext() );
2046 $parserOptions->setRenderReason( 'page-view' );
2047 return $parserOptions;
2051 * Sets the context this Article is executed in
2053 * @param IContextSource $context
2056 public function setContext( $context ) {
2057 $this->mContext
= $context;
2061 * Gets the context this Article is executed in
2063 * @return IContextSource
2066 public function getContext(): IContextSource
{
2067 if ( $this->mContext
instanceof IContextSource
) {
2068 return $this->mContext
;
2070 wfDebug( __METHOD__
. " called and \$mContext is null. " .
2071 "Return RequestContext::getMain()" );
2072 return RequestContext
::getMain();
2077 * Call to WikiPage function for backwards compatibility.
2078 * @see ContentHandler::getActionOverrides
2081 public function getActionOverrides() {
2082 return $this->mPage
->getActionOverrides();
2085 private function getMissingRevisionMsg( int $oldid ): Message
{
2086 // T251066: Try loading the revision from the archive table.
2087 // Show link to view it if it exists and the user has permission to view it.
2088 // (Ignore the given title, if any; look it up from the revision instead.)
2089 $context = $this->getContext();
2090 $revRecord = $this->archivedRevisionLookup
->getArchivedRevisionRecord( null, $oldid );
2093 $revRecord->userCan(
2094 RevisionRecord
::DELETED_TEXT
,
2095 $context->getAuthority()
2097 $context->getAuthority()->isAllowedAny( 'deletedtext', 'undelete' )
2099 return $context->msg(
2100 'missing-revision-permission',
2102 $revRecord->getTimestamp(),
2103 Title
::newFromPageIdentity( $revRecord->getPage() )->getPrefixedDBkey()
2106 return $context->msg( 'missing-revision', $oldid );