Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.36% covered (warning)
72.36%
767 / 1060
33.33% covered (danger)
33.33%
17 / 51
CRAP
0.00% covered (danger)
0.00%
0 / 1
Article
72.43% covered (warning)
72.43%
767 / 1059
33.33% covered (danger)
33.33%
17 / 51
1847.84
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 newPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newFromID
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 newFromTitle
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
2.00
 newFromWikiPage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getRedirectedFrom
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setRedirectedFrom
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clear
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getOldID
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getOldIDFromRequest
44.12% covered (danger)
44.12%
15 / 34
0.00% covered (danger)
0.00%
0 / 1
48.20
 fetchRevisionRecord
80.77% covered (warning)
80.77%
21 / 26
0.00% covered (danger)
0.00%
0 / 1
8.46
 isCurrent
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 getRevIdFetched
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 view
56.58% covered (warning)
56.58%
43 / 76
0.00% covered (danger)
0.00%
0 / 1
52.75
 showProtectionIndicator
95.12% covered (success)
95.12%
39 / 41
0.00% covered (danger)
0.00%
0 / 1
10
 generateContentOutput
89.55% covered (warning)
89.55%
120 / 134
0.00% covered (danger)
0.00%
0 / 1
33.17
 doOutputMetaData
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 postProcessOutput
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 doOutputFromPostProcessedParserCache
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
3.05
 doOutputFromRenderStatus
80.00% covered (warning)
80.00%
20 / 25
0.00% covered (danger)
0.00%
0 / 1
7.39
 adjustDisplayTitle
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 showDiffPage
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
20
 isDiffOnlyView
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getRobotPolicy
57.69% covered (warning)
57.69%
30 / 52
0.00% covered (danger)
0.00%
0 / 1
28.84
 formatRobotPolicy
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
8.30
 showRedirectedFromHeader
34.15% covered (danger)
34.15%
14 / 41
0.00% covered (danger)
0.00%
0 / 1
20.99
 showNamespaceHeader
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
7.05
 showViewFooter
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 showPatrolFooter
68.00% covered (warning)
68.00%
68 / 100
0.00% covered (danger)
0.00%
0 / 1
50.89
 purgePatrolFooterCache
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 showMissingArticle
92.08% covered (success)
92.08%
93 / 101
0.00% covered (danger)
0.00%
0 / 1
26.34
 showViewError
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 showDeletedRevisionHeader
78.95% covered (warning)
78.95%
30 / 38
0.00% covered (danger)
0.00%
0 / 1
6.34
 addMessageBoxStyles
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setOldSubtitle
97.66% covered (success)
97.66%
125 / 128
0.00% covered (danger)
0.00%
0 / 1
16
 addHelpLink
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 render
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 protect
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 unprotect
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 tryFileCache
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 isFileCacheable
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 getParserOutput
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getParserOptions
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 setContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContext
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getActionOverrides
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMissingRevisionMsg
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 usePostProcessingCache
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 setUseLegacyPostprocCache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Page;
8
9use LogicException;
10use MediaWiki\Block\DatabaseBlock;
11use MediaWiki\Block\DatabaseBlockStore;
12use MediaWiki\Cache\HTMLFileCache;
13use MediaWiki\CommentFormatter\CommentFormatter;
14use MediaWiki\Context\IContextSource;
15use MediaWiki\Context\RequestContext;
16use MediaWiki\Debug\DeprecationHelper;
17use MediaWiki\Diff\DifferenceEngine;
18use MediaWiki\EditPage\EditPage;
19use MediaWiki\Exception\PermissionsError;
20use MediaWiki\HookContainer\HookRunner;
21use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
22use MediaWiki\Html\Html;
23use MediaWiki\JobQueue\JobQueueGroup;
24use MediaWiki\JobQueue\Jobs\ParsoidCachePrewarmJob;
25use MediaWiki\Linker\Linker;
26use MediaWiki\Linker\LinkRenderer;
27use MediaWiki\Logging\LogEventsList;
28use MediaWiki\MainConfigNames;
29use MediaWiki\MediaWikiServices;
30use MediaWiki\Message\Message;
31use MediaWiki\Output\OutputPage;
32use MediaWiki\Parser\Parser;
33use MediaWiki\Parser\ParserOptions;
34use MediaWiki\Parser\ParserOutput;
35use MediaWiki\Permissions\Authority;
36use MediaWiki\Permissions\PermissionStatus;
37use MediaWiki\Permissions\RestrictionStore;
38use MediaWiki\RecentChanges\RecentChange;
39use MediaWiki\RecentChanges\RecentChangeLookup;
40use MediaWiki\Revision\ArchivedRevisionLookup;
41use MediaWiki\Revision\BadRevisionException;
42use MediaWiki\Revision\RevisionRecord;
43use MediaWiki\Revision\RevisionStore;
44use MediaWiki\ShadowPage\ShadowPageLoader;
45use MediaWiki\Skin\Skin;
46use MediaWiki\Status\Status;
47use MediaWiki\Title\Title;
48use MediaWiki\User\Options\UserOptionsLookup;
49use MediaWiki\User\UserIdentity;
50use MediaWiki\User\UserNameUtils;
51use StatusValue;
52use Wikimedia\HtmlArmor\HtmlArmor;
53use Wikimedia\IPUtils;
54use Wikimedia\NonSerializable\NonSerializableTrait;
55use Wikimedia\Parsoid\Parsoid;
56use Wikimedia\Rdbms\IConnectionProvider;
57
58/**
59 * Legacy class representing an editable page and handling UI for some page actions.
60 *
61 * This has largely been superseded by WikiPage, with Action subclasses for the
62 * user interface of page actions, and service classes for their backend logic.
63 *
64 * @todo Move and refactor remaining code
65 * @todo Deprecate
66 */
67class Article implements Page {
68    use ProtectedHookAccessorTrait;
69    use NonSerializableTrait;
70    use DeprecationHelper;
71
72    /**
73     * @var IContextSource|null The context this Article is executed in.
74     * If null, RequestContext::getMain() is used.
75     * @deprecated since 1.35, must be private, use {@link getContext}
76     */
77    private $mContext;
78
79    /** @var WikiPage The WikiPage object of this instance */
80    protected $mPage;
81
82    /**
83     * @var int|null The oldid of the article that was requested to be shown,
84     * 0 for the latest revision.
85     */
86    public $mOldId;
87
88    /** @var Title|null Title from which we were redirected here, if any. */
89    public $mRedirectedFrom = null;
90
91    /** @var string|false URL to redirect to or false if none */
92    public $mRedirectUrl = false;
93
94    /**
95     * @var StatusValue|null represents the outcome of fetchRevisionRecord().
96     * $fetchResult->value is the RevisionRecord object, if the operation was successful.
97     */
98    private $fetchResult = null;
99
100    /**
101     * @var ParserOutput|null|false The ParserOutput generated for viewing the page,
102     * initialized by view(). If no ParserOutput could be generated, this is set to false.
103     * @deprecated since 1.32
104     */
105    public $mParserOutput = null;
106
107    /**
108     * @var bool Whether render() was called. With the way subclasses work
109     * here, there doesn't seem to be any other way to stop calling
110     * OutputPage::enableSectionEditLinks() and still have it work as it did before.
111     */
112    protected $viewIsRenderAction = false;
113
114    protected LinkRenderer $linkRenderer;
115    private RevisionStore $revisionStore;
116    private UserNameUtils $userNameUtils;
117    private UserOptionsLookup $userOptionsLookup;
118    private CommentFormatter $commentFormatter;
119    private WikiPageFactory $wikiPageFactory;
120    private JobQueueGroup $jobQueueGroup;
121    private ArchivedRevisionLookup $archivedRevisionLookup;
122    private RecentChangeLookup $recentChangeLookup;
123    protected IConnectionProvider $dbProvider;
124    protected DatabaseBlockStore $blockStore;
125    protected RestrictionStore $restrictionStore;
126    private ShadowPageLoader $shadowPageLoader;
127    private bool $useLegacyPostprocCache;
128
129    /**
130     * @var RevisionRecord|null Revision to be shown
131     *
132     * Initialized by getOldIDFromRequest() or fetchRevisionRecord(). While the output of
133     * Article::view is typically based on this revision, it may be replaced by extensions.
134     */
135    private $mRevisionRecord = null;
136
137    private bool $parsoidPostprocCacheAvailable;
138    private bool $legacyPostprocCacheAvailable;
139
140    /**
141     * @param Title $title
142     * @param int|null $oldId Revision ID, null to fetch from request, zero for current
143     */
144    public function __construct( Title $title, $oldId = null ) {
145        $this->deprecatePublicProperty( 'mContext', '1.35', __CLASS__ );
146
147        $this->mOldId = $oldId;
148        $this->mPage = $this->newPage( $title );
149
150        $services = MediaWikiServices::getInstance();
151        $this->linkRenderer = $services->getLinkRenderer();
152        $this->revisionStore = $services->getRevisionStore();
153        $this->userNameUtils = $services->getUserNameUtils();
154        $this->userOptionsLookup = $services->getUserOptionsLookup();
155        $this->commentFormatter = $services->getCommentFormatter();
156        $this->wikiPageFactory = $services->getWikiPageFactory();
157        $this->jobQueueGroup = $services->getJobQueueGroup();
158        $this->archivedRevisionLookup = $services->getArchivedRevisionLookup();
159        $this->recentChangeLookup = $services->getRecentChangeLookup();
160        $this->dbProvider = $services->getConnectionProvider();
161        $this->blockStore = $services->getDatabaseBlockStore();
162        $this->restrictionStore = $services->getRestrictionStore();
163        $this->shadowPageLoader = $services->getShadowPageLoader();
164        $this->parsoidPostprocCacheAvailable =
165            MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::UsePostprocCacheParsoid );
166        $this->legacyPostprocCacheAvailable =
167            MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::UsePostprocCacheLegacy );
168        $this->useLegacyPostprocCache = false;
169    }
170
171    /**
172     * @param Title $title
173     * @return WikiPage
174     */
175    protected function newPage( Title $title ) {
176        return new WikiPage( $title );
177    }
178
179    /**
180     * Constructor from a page id
181     * @param int $id Article ID to load
182     */
183    public static function newFromID( $id ): ?static {
184        $t = Title::newFromID( $id );
185        return $t === null ? null : new static( $t );
186    }
187
188    /**
189     * Create an Article object of the appropriate class for the given page.
190     *
191     * @param Title $title
192     * @param IContextSource $context
193     */
194    public static function newFromTitle( $title, IContextSource $context ): static {
195        if ( $title->getNamespace() === NS_MEDIA ) {
196            // XXX: This should not be here, but where should it go?
197            $title = Title::makeTitle( NS_FILE, $title->getDBkey() );
198        }
199
200        $page = null;
201        ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
202            // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
203            ->onArticleFromTitle( $title, $page, $context );
204
205        $page ??= match ( $title->getNamespace() ) {
206            NS_FILE => new ImagePage( $title ),
207            NS_CATEGORY => new CategoryPage( $title ),
208            default => new Article( $title )
209        };
210        $page->setContext( $context );
211
212        return $page;
213    }
214
215    /**
216     * Create an Article object of the appropriate class for the given page.
217     *
218     * @param WikiPage $page
219     * @param IContextSource $context
220     * @return Article
221     */
222    public static function newFromWikiPage( WikiPage $page, IContextSource $context ) {
223        $article = self::newFromTitle( $page->getTitle(), $context );
224        $article->mPage = $page; // override to keep process cached vars
225        return $article;
226    }
227
228    /**
229     * Get the page this view was redirected from
230     * @return Title|null
231     * @since 1.28
232     */
233    public function getRedirectedFrom() {
234        return $this->mRedirectedFrom;
235    }
236
237    /**
238     * Tell the page view functions that this view was redirected
239     * from another page on the wiki.
240     */
241    public function setRedirectedFrom( Title $from ) {
242        $this->mRedirectedFrom = $from;
243    }
244
245    /**
246     * Get the title object of the article
247     *
248     * @return Title Title object of this page
249     */
250    public function getTitle() {
251        return $this->mPage->getTitle();
252    }
253
254    /**
255     * Get the WikiPage object of this instance
256     *
257     * @since 1.19
258     * @return WikiPage
259     */
260    public function getPage() {
261        return $this->mPage;
262    }
263
264    public function clear() {
265        $this->mRedirectedFrom = null; # Title object if set
266        $this->mRedirectUrl = false;
267        $this->mRevisionRecord = null;
268        $this->fetchResult = null;
269
270        // TODO hard-deprecate direct access to public fields
271
272        $this->mPage->clear();
273    }
274
275    /**
276     * @see getOldIDFromRequest()
277     * @see getRevIdFetched()
278     *
279     * @return int The oldid of the article that is was requested in the constructor or via the
280     *         context's WebRequest.
281     */
282    public function getOldID() {
283        if ( $this->mOldId === null ) {
284            $this->mOldId = $this->getOldIDFromRequest();
285        }
286
287        return $this->mOldId;
288    }
289
290    /**
291     * Sets $this->mRedirectUrl to a correct URL if the query parameters are incorrect
292     *
293     * @return int The old id for the request
294     */
295    public function getOldIDFromRequest() {
296        $this->mRedirectUrl = false;
297
298        $request = $this->getContext()->getRequest();
299        $oldid = $request->getIntOrNull( 'oldid' );
300
301        if ( $oldid === null ) {
302            return 0;
303        }
304
305        if ( $oldid !== 0 ) {
306            # Load the given revision and check whether the page is another one.
307            # In that case, update this instance to reflect the change.
308            if ( $oldid === $this->mPage->getLatest() ) {
309                $this->mRevisionRecord = $this->mPage->getRevisionRecord();
310            } else {
311                $this->mRevisionRecord = $this->revisionStore->getRevisionById( $oldid );
312                if ( $this->mRevisionRecord !== null ) {
313                    $revPageId = $this->mRevisionRecord->getPageId();
314                    // Revision title doesn't match the page title given?
315                    if ( $this->mPage->getId() !== $revPageId ) {
316                        $this->mPage = $this->wikiPageFactory->newFromID( $revPageId );
317                    }
318                }
319            }
320        }
321
322        $oldRev = $this->mRevisionRecord;
323        if ( $request->getRawVal( 'direction' ) === 'next' ) {
324            $nextid = 0;
325            if ( $oldRev ) {
326                $nextRev = $this->revisionStore->getNextRevision( $oldRev );
327                if ( $nextRev ) {
328                    $nextid = $nextRev->getId();
329                }
330            }
331            if ( $nextid ) {
332                $oldid = $nextid;
333                $this->mRevisionRecord = null;
334            } else {
335                $this->mRedirectUrl = $this->getTitle()->getFullURL( 'redirect=no' );
336            }
337        } elseif ( $request->getRawVal( 'direction' ) === 'prev' ) {
338            $previd = 0;
339            if ( $oldRev ) {
340                $prevRev = $this->revisionStore->getPreviousRevision( $oldRev );
341                if ( $prevRev ) {
342                    $previd = $prevRev->getId();
343                }
344            }
345            if ( $previd ) {
346                $oldid = $previd;
347                $this->mRevisionRecord = null;
348            }
349        }
350
351        return $oldid;
352    }
353
354    /**
355     * Fetches the revision to work on.
356     * The revision is loaded from the database. Refer to $this->fetchResult for the revision
357     * or any errors encountered while loading it.
358     *
359     * Public since 1.35
360     *
361     * @return RevisionRecord|null
362     */
363    public function fetchRevisionRecord() {
364        if ( $this->fetchResult ) {
365            return $this->mRevisionRecord;
366        }
367
368        $oldid = $this->getOldID();
369
370        // $this->mRevisionRecord might already be fetched by getOldIDFromRequest()
371        if ( !$this->mRevisionRecord ) {
372            if ( !$oldid ) {
373                $this->mRevisionRecord = $this->mPage->getRevisionRecord();
374
375                if ( !$this->mRevisionRecord ) {
376                    wfDebug( __METHOD__ . " failed to find page data for title " .
377                        $this->getTitle()->getPrefixedText() );
378
379                    // Output for this case is done by showMissingArticle().
380                    $this->fetchResult = StatusValue::newFatal( 'noarticletext' );
381                    return null;
382                }
383            } else {
384                $this->mRevisionRecord = $this->revisionStore->getRevisionById( $oldid );
385
386                if ( !$this->mRevisionRecord ) {
387                    wfDebug( __METHOD__ . " failed to load revision, rev_id $oldid" );
388
389                    $this->fetchResult = StatusValue::newFatal( $this->getMissingRevisionMsg( $oldid ) );
390                    return null;
391                }
392            }
393        }
394
395        if ( !$this->mRevisionRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getContext()->getAuthority() ) ) {
396            wfDebug( __METHOD__ . " failed to retrieve content of revision " . $this->mRevisionRecord->getId() );
397
398            // Output for this case is done by showDeletedRevisionHeader().
399            // title used in wikilinks, should not contain whitespaces
400            $this->fetchResult = new StatusValue();
401            $title = $this->getTitle()->getPrefixedDBkey();
402
403            if ( $this->mRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ) {
404                $this->fetchResult->fatal( 'rev-suppressed-text' );
405            } else {
406                $this->fetchResult->fatal( 'rev-deleted-text-permission', $title );
407            }
408
409            return null;
410        }
411
412        $this->fetchResult = StatusValue::newGood( $this->mRevisionRecord );
413        return $this->mRevisionRecord;
414    }
415
416    /**
417     * Returns true if the currently-referenced revision is the current edit
418     * to this page (and it exists).
419     * @return bool
420     */
421    public function isCurrent() {
422        # If no oldid, this is the current version.
423        if ( $this->getOldID() == 0 ) {
424            return true;
425        }
426
427        return $this->mPage->exists() &&
428            $this->mRevisionRecord &&
429            $this->mRevisionRecord->isCurrent();
430    }
431
432    /**
433     * Use this to fetch the rev ID used on page views
434     *
435     * Before fetchRevisionRecord was called, this returns the page's latest revision,
436     * regardless of what getOldID() returns.
437     *
438     * @return int Revision ID of last article revision
439     */
440    public function getRevIdFetched() {
441        if ( $this->fetchResult && $this->fetchResult->isOK() ) {
442            /** @var RevisionRecord $rev */
443            $rev = $this->fetchResult->getValue();
444            return $rev->getId();
445        } else {
446            return $this->mPage->getLatest();
447        }
448    }
449
450    /**
451     * This is the default action of the index.php entry point: just view the
452     * page of the given title.
453     */
454    public function view() {
455        $context = $this->getContext();
456        $useFileCache = $context->getConfig()->get( MainConfigNames::UseFileCache );
457
458        # Get variables from query string
459        # As side effect this will load the revision and update the title
460        # in a revision ID is passed in the request, so this should remain
461        # the first call of this method even if $oldid is used way below.
462        $oldid = $this->getOldID();
463
464        $authority = $context->getAuthority();
465        # Another check in case getOldID() is altering the title
466        $permissionStatus = PermissionStatus::newEmpty();
467        if ( !$authority
468            ->authorizeRead( 'read', $this->getTitle(), $permissionStatus )
469        ) {
470            wfDebug( __METHOD__ . ": denied on secondary read check" );
471            throw new PermissionsError( 'read', $permissionStatus );
472        }
473
474        $outputPage = $context->getOutput();
475        # getOldID() may as well want us to redirect somewhere else
476        if ( $this->mRedirectUrl ) {
477            $outputPage->redirect( $this->mRedirectUrl );
478            wfDebug( __METHOD__ . ": redirecting due to oldid" );
479
480            return;
481        }
482
483        # If we got diff in the query, we want to see a diff page instead of the article.
484        if ( $context->getRequest()->getCheck( 'diff' ) ) {
485            wfDebug( __METHOD__ . ": showing diff page" );
486            $this->showDiffPage();
487            return;
488        }
489
490        $this->showProtectionIndicator();
491
492        # Set page title (may be overridden from ParserOutput if title conversion is enabled or DISPLAYTITLE is used)
493        $outputPage->setPageTitle( Parser::formatPageTitle(
494            str_replace( '_', ' ', $this->getTitle()->getNsText() ),
495            ':',
496            $this->getTitle()->getText(),
497            $this->getTitle()->getPageLanguage()
498        ) );
499
500        $outputPage->setArticleFlag( true );
501        # Allow frames by default
502        $outputPage->getMetadata()->setPreventClickjacking( false );
503
504        $parserOptions = $this->getParserOptions( $oldid );
505
506        $poOptions = [];
507        # Render printable version, use printable version cache
508        if ( $outputPage->isPrintable() ) {
509            $parserOptions->setIsPrintable( true );
510            $parserOptions->setSuppressSectionEditLinks();
511            $this->addMessageBoxStyles( $outputPage );
512            $outputPage->prependHTML(
513                Html::warningBox(
514                    $outputPage->msg( 'printableversion-deprecated-warning' )->escaped()
515                )
516            );
517        } elseif ( $this->viewIsRenderAction || !$this->isCurrent() ||
518            !$authority->probablyCan( 'edit', $this->getTitle() )
519        ) {
520            $parserOptions->setSuppressSectionEditLinks();
521        }
522
523        # Try client and file cache
524        if ( $oldid === 0 && $this->mPage->checkTouched() ) {
525            # Try to stream the output from file cache
526            if ( $useFileCache && $this->tryFileCache() ) {
527                wfDebug( __METHOD__ . ": done file cache" );
528                # tell wgOut that output is taken care of
529                $outputPage->disable();
530                $this->mPage->doViewUpdates( $authority );
531
532                return;
533            }
534        }
535
536        $this->showRedirectedFromHeader();
537        $this->showNamespaceHeader();
538
539        if ( $this->viewIsRenderAction ) {
540            $poOptions += [ 'absoluteURLs' => true ];
541        }
542        $poOptions += [ 'includeDebugInfo' => true ];
543
544        try {
545            $continue =
546                $this->generateContentOutput( $authority, $parserOptions, $oldid, $outputPage, $poOptions );
547        } catch ( BadRevisionException ) {
548            $continue = false;
549            $this->showViewError( wfMessage( 'badrevision' )->text() );
550        }
551
552        if ( !$continue ) {
553            return;
554        }
555
556        # For the main page, overwrite the <title> element with the con-
557        # tents of 'pagetitle-view-mainpage' instead of the default (if
558        # that's not empty).
559        # This message always exists because it is in the i18n files
560        if ( $this->getTitle()->isMainPage() ) {
561            $msg = $context->msg( 'pagetitle-view-mainpage' )->inContentLanguage();
562            if ( !$msg->isDisabled() ) {
563                $outputPage->setHTMLTitle( $msg->text() );
564            }
565        }
566
567        // Enable 1-day CDN cache on this response
568        //
569        // To reduce impact of lost or delayed HTTP purges, the adaptive TTL will
570        // raise the TTL for pages not recently edited, upto $wgCdnMaxAge.
571        // This could use getTouched(), but that could be scary for major template edits.
572        $outputPage->adaptCdnTTL( $this->mPage->getTimestamp(), 86_400 );
573
574        $this->showViewFooter();
575        $this->mPage->doViewUpdates( $authority, $this->fetchRevisionRecord() );
576
577        # Load the postEdit module if the user just saved this revision
578        # See also EditPage::setPostEditCookie
579        $request = $context->getRequest();
580        $cookieKey = EditPage::POST_EDIT_COOKIE_KEY_PREFIX . $this->getRevIdFetched();
581        $postEdit = $request->getCookie( $cookieKey );
582        if ( $postEdit ) {
583            # Clear the cookie. This also prevents caching of the response.
584            $request->response()->clearCookie( $cookieKey );
585            $outputPage->addJsConfigVars( 'wgPostEdit', $postEdit );
586            $outputPage->addModules( 'mediawiki.action.view.postEdit' ); // FIXME: test this
587            if ( $this->getContext()->getConfig()->get( MainConfigNames::EnableEditRecovery )
588                && $this->userOptionsLookup->getOption( $this->getContext()->getUser(), 'editrecovery' )
589            ) {
590                $outputPage->addModules( 'mediawiki.editRecovery.postEdit' );
591            }
592        }
593    }
594
595    /**
596     * Show a lock icon above the article body if the page is protected.
597     */
598    public function showProtectionIndicator(): void {
599        $title = $this->getTitle();
600        $context = $this->getContext();
601        $outputPage = $context->getOutput();
602
603        $protectionIndicatorsAreEnabled = $context->getConfig()
604            ->get( MainConfigNames::EnableProtectionIndicators );
605
606        if ( !$protectionIndicatorsAreEnabled || $title->isMainPage() ) {
607            return;
608        }
609
610        $protection = $this->restrictionStore->getRestrictions( $title, 'edit' );
611
612        $cascadeProtection = $this->restrictionStore->getCascadeProtectionSources( $title )[1];
613
614        $isCascadeProtected = array_key_exists( 'edit', $cascadeProtection );
615
616        if ( !$protection && !$isCascadeProtected ) {
617            return;
618        }
619
620        if ( $isCascadeProtected ) {
621            // Cascade-protected pages are protected at the sysop level. So it
622            // should not matter if we take the protection level of the first
623            // or last page that is being cascaded to the current page.
624            $protectionLevel = $cascadeProtection['edit'][0];
625        } else {
626            $protectionLevel = $protection[0];
627        }
628
629        // Protection levels are stored in the database as plain text, but
630        // they are expected to be valid protection levels. So we should be able to
631        // safely use them. However phan thinks this could be a XSS problem so we
632        // are being paranoid and escaping them once more.
633        $protectionLevel = htmlspecialchars( $protectionLevel );
634
635        $protectionExpiry = $this->restrictionStore->getRestrictionExpiry( $title, 'edit' );
636        $formattedProtectionExpiry = $context->getLanguage()
637            ->formatExpiry( $protectionExpiry ?? '' );
638
639        $protectionMsgKey = 'protection-indicator-title';
640        if ( $protectionExpiry === 'infinity' || !$protectionExpiry ) {
641            $protectionMsgKey = 'protection-indicator-title-infinity';
642        }
643
644        // Potential values: 'protection-sysop', 'protection-autoconfirmed',
645        // 'protection-sysop-cascade' etc.
646        // If the wiki has more protection levels, the additional ids that get
647        // added take the form 'protection-<protectionLevel>' and
648        // 'protection-<protectionLevel>-cascade'.
649        $protectionIndicatorId = 'protection-' . $protectionLevel . ( $isCascadeProtected ? '-cascade' : '' );
650
651        $protectionMsg = $outputPage->msg(
652            $protectionMsgKey,
653            // Messages: restriction-level-sysop, restriction-level-autoconfirmed
654            $outputPage->msg( "restriction-level-$protectionLevel" ),
655            $formattedProtectionExpiry
656        )->text();
657
658        // Use a trick similar to the one used in Action::addHelpLink() to allow wikis
659        // to customize where the help link points to.
660        $protectionHelpLink = $outputPage->msg( $protectionIndicatorId . '-helppage' );
661        if ( $protectionHelpLink->isDisabled() ) {
662            $protectionHelpLink = 'https://mediawiki.org/wiki/Special:MyLanguage/Help:Protection';
663        } else {
664            $protectionHelpLink = Skin::makeInternalOrExternalUrl( $protectionHelpLink->text() );
665        }
666
667        $outputPage->setIndicators( [
668            $protectionIndicatorId => Html::rawElement( 'a', [
669                'class' => 'mw-protection-indicator-icon--lock',
670                'title' => $protectionMsg,
671                'href' => $protectionHelpLink
672            ],
673            // Screen reader-only text describing the same thing as
674            // was mentioned in the title attribute.
675            Html::element( 'span', [], $protectionMsg ) )
676        ] );
677
678        $outputPage->addModuleStyles( 'mediawiki.protectionIndicators.styles' );
679    }
680
681    /**
682     * Determines the desired ParserOutput and passes it to $outputPage.
683     *
684     * @param Authority $performer
685     * @param ParserOptions $parserOptions
686     * @param int $oldid
687     * @param OutputPage $outputPage
688     * @param array $textOptions
689     *
690     * @return bool True if further processing like footer generation should be applied,
691     *              false to skip further processing.
692     */
693    private function generateContentOutput(
694        Authority $performer,
695        ParserOptions $parserOptions,
696        int $oldid,
697        OutputPage $outputPage,
698        array $textOptions
699    ): bool {
700        # Should the parser cache be used?
701        $useParserCache = true;
702        $pOutput = null;
703        $parserOutputAccess = MediaWikiServices::getInstance()->getParserOutputAccess();
704
705        // NOTE: $outputDone and $useParserCache may be changed by the hook
706        $this->getHookRunner()->onArticleViewHeader( $this, $outputDone, $useParserCache );
707        if ( $outputDone ) {
708            if ( $outputDone instanceof ParserOutput ) {
709                $pOutput = $outputDone;
710            }
711
712            if ( $pOutput ) {
713                $this->doOutputMetaData( $pOutput, $outputPage );
714            }
715            return true;
716        }
717
718        // Early abort if the page doesn't exist
719        if ( !$this->mPage->exists() ) {
720            wfDebug( __METHOD__ . ": showing missing article" );
721            $this->showMissingArticle();
722            $this->mPage->doViewUpdates( $performer );
723            return false; // skip all further output to OutputPage
724        }
725
726        // Augment the parser options
727        $skin = $outputPage->getSkin();
728        $skin->setParserOptions( $parserOptions );
729        $skinOptions = $skin->getOptions();
730        $textOptions += [
731            // T371022, T410923
732            'allowClone' => $this->getContext()->getConfig()->get( MainConfigNames::CloneArticleParserOutput ),
733            'skin' => $skin,
734            'injectTOC' => $skinOptions['toc'],
735        ];
736        foreach ( $textOptions as $key => $value ) {
737            // allowClone will disappear and should not impact cache
738            // userLang is a duplicate of userlang and should be reconciled with it
739            if ( $key === 'allowClone' || $key === 'userLang' ) {
740                continue;
741            }
742            if ( $key === 'enableSectionEditLinks' ) {
743                if ( $value === false ) {
744                    wfDeprecated( __METHOD__ . " with deprecated textOption $key set to false", "1.46" );
745                    $parserOptions->setSuppressSectionEditLinks();
746                }
747                continue;
748            }
749            if ( !in_array( $key, ParserOptions::$postprocOptions, true ) ) {
750                wfDeprecated( __METHOD__ . " with unknown textOption $key", "1.46" );
751            } else {
752                $parserOptions->setOption( $key, $value );
753            }
754        }
755        if ( $this->usePostProcessingCache( $parserOptions ) ) {
756            $parserOptions->enablePostproc();
757        }
758
759        // Try the latest parser cache
760        // NOTE: try latest-revision cache first to avoid loading revision.
761        if ( $useParserCache && !$oldid ) {
762            $pOutput = $parserOutputAccess->getCachedParserOutput(
763                $this->getPage(),
764                $parserOptions,
765                null,
766                [
767                    // we already checked
768                    ParserOutputAccess::OPT_NO_AUDIENCE_CHECK => true,
769                ],
770            );
771
772            if ( $pOutput ) {
773                if ( !$this->usePostProcessingCache( $parserOptions ) ) {
774                    $pOutput = $this->postProcessOutput( $pOutput, $parserOptions, $textOptions, $skin );
775                }
776                $this->doOutputFromPostProcessedParserCache( $pOutput, $outputPage );
777                $this->doOutputMetaData( $pOutput, $outputPage );
778                return true;
779            }
780        }
781
782        $rev = $this->fetchRevisionRecord();
783        if ( !$this->fetchResult->isOK() ) {
784            $this->showViewError( Status::wrap( $this->fetchResult )->getWikiText(
785                false, false, $this->getContext()->getLanguage()
786            ) );
787            return true;
788        }
789
790        # Are we looking at an old revision
791        if ( $oldid ) {
792            $this->setOldSubtitle( $oldid );
793
794            if ( !$this->showDeletedRevisionHeader() ) {
795                wfDebug( __METHOD__ . ": cannot view deleted revision" );
796                return false; // skip all further output to OutputPage
797            }
798
799            // Try the old revision parser cache
800            // NOTE: Repeating cache check for old revision to avoid fetching $rev
801            // before it's absolutely necessary.
802            if ( $useParserCache ) {
803                $pOutput = $parserOutputAccess->getCachedParserOutput(
804                    $this->getPage(),
805                    $parserOptions,
806                    $rev,
807                    [
808                         // we already checked in fetchRevisionRecord
809                        ParserOutputAccess::OPT_NO_AUDIENCE_CHECK => true,
810                    ],
811                );
812
813                if ( $pOutput ) {
814                    if ( !$this->usePostProcessingCache( $parserOptions ) ) {
815                        $pOutput = $this->postProcessOutput( $pOutput, $parserOptions, $textOptions, $skin );
816                    }
817                    $this->doOutputFromPostProcessedParserCache( $pOutput, $outputPage );
818                    $this->doOutputMetaData( $pOutput, $outputPage );
819                    return true;
820                }
821            }
822        }
823
824        # Ensure that UI elements requiring revision ID have
825        # the correct version information. (This may be overwritten after creation of ParserOutput)
826        $outputPage->setRevisionId( $this->getRevIdFetched() );
827        $outputPage->setRevisionIsCurrent( $rev->isCurrent() );
828        # Preload timestamp to avoid a DB hit
829        $outputPage->getMetadata()->setRevisionTimestamp( $rev->getTimestamp() );
830
831        # Pages containing custom CSS or JavaScript get special treatment
832        if ( $this->getTitle()->isSiteConfigPage() || $this->getTitle()->isUserConfigPage() ) {
833            $dir = $this->getContext()->getLanguage()->getDir();
834            $lang = $this->getContext()->getLanguage()->getHtmlCode();
835
836            $outputPage->wrapWikiMsg(
837                "<div id='mw-clearyourcache' lang='$lang' dir='$dir' class='mw-content-$dir'>\n$1\n</div>",
838                'clearyourcache'
839            );
840            $outputPage->addModuleStyles( 'mediawiki.action.styles' );
841        } elseif ( !$this->getHookRunner()->onArticleRevisionViewCustom(
842            $rev,
843            $this->getTitle(),
844            $oldid,
845            $outputPage )
846        ) {
847            // NOTE: sync with hooks called in DifferenceEngine::renderNewRevision()
848            // Allow extensions do their own custom view for certain pages
849            $this->doOutputMetaData( $pOutput, $outputPage );
850            return true;
851        }
852
853        # Run the parse, protected by a pool counter
854        wfDebug( __METHOD__ . ": doing uncached parse" );
855
856        $opt = [];
857
858        // we already checked the cache in case 2, don't check again; but if we're using postproc,
859        // we still want to check if we have a main parser cache entry.
860        if ( $this->usePostProcessingCache( $parserOptions ) ) {
861            $opt[ ParserOutputAccess::OPT_NO_POSTPROC_CACHE ] = true;
862        } else {
863            $opt[ ParserOutputAccess::OPT_NO_CHECK_CACHE ] = true;
864        }
865
866        // we already checked in fetchRevisionRecord()
867        $opt[ ParserOutputAccess::OPT_NO_AUDIENCE_CHECK ] = true;
868
869        // enable stampede protection
870        $opt[ ParserOutputAccess::OPT_POOL_COUNTER ]
871            = ParserOutputAccess::POOL_COUNTER_ARTICLE_VIEW;
872
873        // allow stale cached content to be served
874        $opt[ ParserOutputAccess::OPT_POOL_COUNTER_FALLBACK ] = true;
875
876        // Attempt to trigger WikiPage::triggerOpportunisticLinksUpdate
877        // Ideally this should not be the responsibility of the ParserCache to control this.
878        // See https://phabricator.wikimedia.org/T329842#8816557 for more context.
879        $opt[ ParserOutputAccess::OPT_LINKS_UPDATE ] = true;
880
881        if ( !$rev->getId() || !$useParserCache ) {
882            // fake revision or uncacheable options
883            $opt[ ParserOutputAccess::OPT_NO_CACHE ] = true;
884        }
885
886        $renderStatus = $parserOutputAccess->getParserOutput(
887            $this->getPage(),
888            $parserOptions,
889            $rev,
890            $opt
891        );
892
893        // T327164: If parsoid cache warming is enabled, we want to ensure that the page
894        // the user is currently looking at has a cached parsoid rendering, in case they
895        // open visual editor. The cache entry would typically be missing if it has expired
896        // from the cache or it was invalidated by RefreshLinksJob. When "traditional"
897        // parser output has been invalidated by RefreshLinksJob, we will render it on
898        // the fly when a user requests the page, and thereby populate the cache again,
899        // per the code above.
900        // The code below is intended to do the same for parsoid output, but asynchronously
901        // in a job, so the user does not have to wait.
902        // Note that we get here if the traditional parser output was missing from the cache.
903        // We do not check if the parsoid output is present in the cache, because that check
904        // takes time. The assumption is that if we have traditional parser output
905        // cached, we probably also have parsoid output cached.
906        // So we leave it to ParsoidCachePrewarmJob to determine whether or not parsing is
907        // needed.
908        if ( $oldid === 0 || $oldid === $this->getPage()->getLatest() ) {
909            $parsoidCacheWarmingEnabled = $this->getContext()->getConfig()
910                ->get( MainConfigNames::ParsoidCacheConfig )['WarmParsoidParserCache'];
911
912            if ( $parsoidCacheWarmingEnabled ) {
913                $parsoidJobSpec = ParsoidCachePrewarmJob::newSpec(
914                    $rev->getId(),
915                    $this->getPage()->toPageRecord(),
916                    [ 'causeAction' => 'view' ]
917                );
918                $this->jobQueueGroup->lazyPush( $parsoidJobSpec );
919            }
920        }
921
922        $this->doOutputFromRenderStatus(
923            $renderStatus,
924            $outputPage,
925            $parserOptions,
926            $textOptions,
927        );
928
929        if ( !$renderStatus->isOK() ) {
930            return true;
931        }
932
933        $pOutput = $renderStatus->getValue();
934        $this->doOutputMetaData( $pOutput, $outputPage );
935        return true;
936    }
937
938    private function doOutputMetaData( ?ParserOutput $pOutput, OutputPage $outputPage ) {
939        # Adjust title for main page & pages with displaytitle
940        if ( $pOutput ) {
941            $this->adjustDisplayTitle( $pOutput );
942
943            // It would be nice to automatically set this during the first call
944            // to OutputPage::addParserOutputMetadata, but we can't because doing
945            // so would break non-pageview actions where OutputPage::getContLangForJS
946            // has different requirements.
947            $pageLang = $pOutput->getLanguage();
948            if ( $pageLang ) {
949                $outputPage->setContentLangForJS( $pageLang );
950            }
951        }
952
953        # Check for any __NOINDEX__ tags on the page using $pOutput
954        $policy = $this->getRobotPolicy( 'view', $pOutput ?: null );
955        $outputPage->getMetadata()->setIndexPolicy( $policy['index'] );
956        $outputPage->setFollowPolicy( $policy['follow'] ); // FIXME: test this
957
958        $this->mParserOutput = $pOutput;
959    }
960
961    private function postProcessOutput(
962        ParserOutput $pOutput, ParserOptions $parserOptions, array $textOptions, Skin $skin
963    ): ParserOutput {
964        $skinOptions = $skin->getOptions();
965        $textOptions += [
966            // T371022, T410923
967            'allowClone' => $this->getContext()->getConfig()->get( MainConfigNames::CloneArticleParserOutput ),
968            'skin' => $skin,
969            'injectTOC' => $skinOptions['toc'],
970        ];
971        $pipeline = MediaWikiServices::getInstance()->getDefaultOutputPipeline();
972        $pOutput = $pipeline->run( $pOutput, $parserOptions, $textOptions );
973        return $pOutput;
974    }
975
976    private function doOutputFromPostProcessedParserCache(
977        ParserOutput $pOutput,
978        OutputPage $outputPage,
979    ) {
980        # Ensure that UI elements requiring revision ID have
981        # the correct version information.
982        $oldid = $pOutput->getCacheRevisionId() ?? $this->getRevIdFetched();
983        $outputPage->setRevisionId( $oldid );
984        $outputPage->setRevisionIsCurrent( $oldid === $this->mPage->getLatest() );
985
986        $outputPage->addPostProcessedParserOutput( $pOutput );
987
988        if ( $pOutput->getRedirectHeader() !== null ) {
989            $outputPage->addSubtitle( "<span id=\"redirectsub\">" .
990                $this->getContext()->msg( 'redirectpagesub' )->parse() . "</span>" );
991        }
992
993        # Preload timestamp to avoid a DB hit
994        $cachedTimestamp = $pOutput->getRevisionTimestamp();
995        if ( $cachedTimestamp !== null ) {
996            $outputPage->getMetadata()->setRevisionTimestamp( $cachedTimestamp );
997            $this->mPage->setTimestamp( $cachedTimestamp );
998        }
999    }
1000
1001    private function doOutputFromRenderStatus(
1002        StatusValue $renderStatus,
1003        OutputPage $outputPage,
1004        ParserOptions $parserOptions,
1005        array $textOptions,
1006    ) {
1007        $context = $this->getContext();
1008        if ( !$renderStatus->isOK() ) {
1009            $this->showViewError( Status::wrap( $renderStatus )->getWikiText(
1010                false, 'view-pool-error', $context->getLanguage()
1011            ) );
1012            return;
1013        }
1014
1015        $pOutput = $renderStatus->getValue();
1016
1017        // Cache stale ParserOutput object with a short expiry
1018        if ( $renderStatus->hasMessage( 'view-pool-dirty-output' ) ) {
1019            $outputPage->lowerCdnMaxage( $context->getConfig()->get( MainConfigNames::CdnMaxageStale ) );
1020            $outputPage->setLastModified( $pOutput->getCacheTime() );
1021            $staleReason = $renderStatus->hasMessage( 'view-pool-contention' )
1022                ? $context->msg( 'view-pool-contention' )->escaped()
1023                : $context->msg( 'view-pool-timeout' )->escaped();
1024            $outputPage->addHTML( "<!-- parser cache is expired, " .
1025                "sending anyway due to $staleReason-->\n" );
1026
1027            // Ensure OutputPage knowns the id from the dirty cache, but keep the current flag (T341013)
1028            $cachedId = $pOutput->getCacheRevisionId();
1029            if ( $cachedId !== null ) {
1030                $outputPage->setRevisionId( $cachedId );
1031                $outputPage->getMetadata()->setRevisionTimestamp( $pOutput->getRevisionTimestamp() );
1032            }
1033        }
1034
1035        // TODO this will probably need to be conditional on cache access and/or hoisted one level above but for
1036        // now let's keep things in the same place and avoid editing StatusValues.
1037        if ( !$this->usePostProcessingCache( $parserOptions ) ) {
1038            $pOutput = $this->postProcessOutput( $pOutput, $parserOptions, $textOptions, $outputPage->getSkin() );
1039        }
1040
1041        $outputPage->addPostProcessedParserOutput( $pOutput );
1042
1043        if ( $pOutput->getRedirectHeader() !== null ) {
1044            $outputPage->addSubtitle( "<span id=\"redirectsub\">" .
1045                $context->msg( 'redirectpagesub' )->parse() . "</span>" );
1046        }
1047    }
1048
1049    /**
1050     * Adjust title for pages with displaytitle, -{T|}- or language conversion
1051     */
1052    public function adjustDisplayTitle( ParserOutput $pOutput ) {
1053        $out = $this->getContext()->getOutput();
1054
1055        # Adjust the title if it was set by displaytitle, -{T|}- or language conversion
1056        $titleText = $pOutput->getTitleText();
1057        if ( $titleText !== '' ) {
1058            # XXX T36514 / T314399 / T306440: we should have a language here
1059            # and split the namespace
1060            $out->setPageTitle( $titleText );
1061            $out->setDisplayTitle( $titleText );
1062        }
1063    }
1064
1065    /**
1066     * Show a diff page according to current request variables. For use within
1067     * Article::view() only, other callers should use the DifferenceEngine class.
1068     */
1069    protected function showDiffPage() {
1070        $context = $this->getContext();
1071        $outputPage = $context->getOutput();
1072        $outputPage->addBodyClasses( 'mw-article-diff' );
1073        $request = $context->getRequest();
1074        $diff = $request->getVal( 'diff' );
1075        $rcid = $request->getInt( 'rcid' );
1076        $purge = $request->getRawVal( 'action' ) === 'purge';
1077        $unhide = $request->getInt( 'unhide' ) === 1;
1078        $oldid = $this->getOldID();
1079
1080        $rev = $this->fetchRevisionRecord();
1081
1082        if ( !$rev ) {
1083            // T213621: $rev maybe null due to either lack of permission to view the
1084            // revision or actually not existing. So let's try loading it from the id
1085            $rev = $this->revisionStore->getRevisionById( $oldid );
1086        }
1087
1088        $services = MediaWikiServices::getInstance();
1089
1090        if ( $rev ) {
1091            $contentHandler = $services
1092                ->getContentHandlerFactory()
1093                ->getContentHandler(
1094                    $rev->getMainContentModel()
1095                );
1096
1097            $de = $contentHandler->createDifferenceEngine(
1098                $context,
1099                $oldid,
1100                $diff,
1101                $rcid,
1102                $purge,
1103                $unhide
1104            );
1105        } else {
1106            // We don't have a content handler, so use the default difference engine
1107            // (this will fail eventually, but letting it get this far avoids the need
1108            // to duplicate the logic for printing the failure message)
1109            $de = new DifferenceEngine(
1110                $context,
1111                $oldid,
1112                $diff,
1113                $rcid,
1114                $purge,
1115                $unhide
1116            );
1117        }
1118
1119        $diffType = $request->getVal( 'diff-type' );
1120
1121        if ( $diffType === null ) {
1122            $diffType = $this->userOptionsLookup
1123                ->getOption( $context->getUser(), 'diff-type' );
1124        } else {
1125            $de->setExtraQueryParams( [ 'diff-type' => $diffType ] );
1126        }
1127
1128        $de->setSlotDiffOptions( [
1129            'diff-type' => $diffType,
1130            'expand-url' => $this->viewIsRenderAction,
1131            'inline-toggle' => true,
1132        ] );
1133        $de->showDiffPage( $this->isDiffOnlyView() );
1134
1135        // Run view updates for the newer revision being diffed (and shown
1136        // below the diff if not diffOnly).
1137        $this->mPage->doViewUpdates( $context->getAuthority(), $de->getNewRevision() );
1138
1139        // Add link to help page; see T321569
1140        $context->getOutput()->addHelpLink( 'Help:Diff' );
1141    }
1142
1143    protected function isDiffOnlyView(): bool {
1144        return $this->getContext()->getRequest()->getBool(
1145            'diffonly',
1146            $this->userOptionsLookup->getBoolOption( $this->getContext()->getUser(), 'diffonly' )
1147        );
1148    }
1149
1150    /**
1151     * Get the robot policy to be used for the current view
1152     * @param string $action The action= GET parameter
1153     * @param ParserOutput|null $pOutput
1154     * @return string[] The policy that should be set
1155     * @todo actions other than 'view'
1156     */
1157    public function getRobotPolicy( $action, ?ParserOutput $pOutput = null ) {
1158        $context = $this->getContext();
1159        $mainConfig = $context->getConfig();
1160        $articleRobotPolicies = $mainConfig->get( MainConfigNames::ArticleRobotPolicies );
1161        $namespaceRobotPolicies = $mainConfig->get( MainConfigNames::NamespaceRobotPolicies );
1162        $defaultRobotPolicy = $mainConfig->get( MainConfigNames::DefaultRobotPolicy );
1163        $title = $this->getTitle();
1164        $ns = $title->getNamespace();
1165
1166        # Don't index user and user talk pages for blocked users (T13443)
1167        if ( $ns === NS_USER || $ns === NS_USER_TALK ) {
1168            $specificTarget = null;
1169            $vagueTarget = null;
1170            $titleText = $title->getText();
1171            if ( IPUtils::isValid( $titleText ) ) {
1172                $vagueTarget = $titleText;
1173            } else {
1174                $specificTarget = $title->getRootText();
1175            }
1176            $block = $this->blockStore->newFromTarget(
1177                $specificTarget, $vagueTarget, false, DatabaseBlockStore::AUTO_NONE );
1178            if ( $block instanceof DatabaseBlock ) {
1179                return [
1180                    'index' => 'noindex',
1181                    'follow' => 'nofollow'
1182                ];
1183            }
1184        }
1185
1186        if ( $this->mPage->getId() === 0 || $this->getOldID() ) {
1187            # Non-articles (special pages etc), and old revisions
1188            return [
1189                'index' => 'noindex',
1190                'follow' => 'nofollow'
1191            ];
1192        } elseif ( $context->getOutput()->isPrintable() ) {
1193            # Discourage indexing of printable versions, but encourage following
1194            return [
1195                'index' => 'noindex',
1196                'follow' => 'follow'
1197            ];
1198        } elseif ( $context->getRequest()->getInt( 'curid' ) ) {
1199            # For ?curid=x urls, disallow indexing
1200            return [
1201                'index' => 'noindex',
1202                'follow' => 'follow'
1203            ];
1204        }
1205
1206        # Otherwise, construct the policy based on the various config variables.
1207        $policy = self::formatRobotPolicy( $defaultRobotPolicy );
1208
1209        if ( isset( $namespaceRobotPolicies[$ns] ) ) {
1210            # Honour customised robot policies for this namespace
1211            $policy = array_merge(
1212                $policy,
1213                self::formatRobotPolicy( $namespaceRobotPolicies[$ns] )
1214            );
1215        }
1216        $namespaceInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1217        if (
1218            $namespaceInfo->canUseNoindex( $title->getNamespace() ) &&
1219            $pOutput && $pOutput->getIndexPolicy()
1220        ) {
1221            # __INDEX__ and __NOINDEX__ magic words, if allowed. Incorporates
1222            # a final check that we have really got the parser output.
1223            $policy['index'] = $pOutput->getIndexPolicy();
1224        }
1225
1226        if ( isset( $articleRobotPolicies[$title->getPrefixedText()] ) ) {
1227            # (T16900) site config can override user-defined __INDEX__ or __NOINDEX__
1228            $policy = array_merge(
1229                $policy,
1230                self::formatRobotPolicy( $articleRobotPolicies[$title->getPrefixedText()] )
1231            );
1232        }
1233
1234        return $policy;
1235    }
1236
1237    /**
1238     * Converts a String robot policy into an associative array, to allow
1239     * merging of several policies using array_merge().
1240     * @param array|string $policy Returns empty array on null/false/'', transparent
1241     *   to already-converted arrays, converts string.
1242     * @return array 'index' => \<indexpolicy\>, 'follow' => \<followpolicy\>
1243     */
1244    public static function formatRobotPolicy( $policy ) {
1245        if ( is_array( $policy ) ) {
1246            return $policy;
1247        } elseif ( !$policy ) {
1248            return [];
1249        }
1250
1251        $arr = [];
1252        foreach ( explode( ',', $policy ) as $var ) {
1253            $var = trim( $var );
1254            if ( $var === 'index' || $var === 'noindex' ) {
1255                $arr['index'] = $var;
1256            } elseif ( $var === 'follow' || $var === 'nofollow' ) {
1257                $arr['follow'] = $var;
1258            }
1259        }
1260
1261        return $arr;
1262    }
1263
1264    /**
1265     * If this request is a redirect view, send "redirected from" subtitle to
1266     * the output. Returns true if the header was needed, false if this is not
1267     * a redirect view. Handles both local and remote redirects.
1268     *
1269     * @return bool
1270     */
1271    public function showRedirectedFromHeader() {
1272        $context = $this->getContext();
1273        $redirectSources = $context->getConfig()->get( MainConfigNames::RedirectSources );
1274        $outputPage = $context->getOutput();
1275        $request = $context->getRequest();
1276        $rdfrom = $request->getVal( 'rdfrom' );
1277
1278        // Construct a URL for the current page view, but with the target title
1279        $query = $request->getQueryValues();
1280        unset( $query['rdfrom'] );
1281        unset( $query['title'] );
1282        if ( $this->getTitle()->isRedirect() ) {
1283            // Prevent double redirects
1284            $query['redirect'] = 'no';
1285        }
1286        $redirectTargetUrl = $this->getTitle()->getLinkURL( $query );
1287
1288        if ( $this->mRedirectedFrom ) {
1289            // This is an internally redirected page view.
1290            // We'll need a backlink to the source page for navigation.
1291            if ( $this->getHookRunner()->onArticleViewRedirect( $this ) ) {
1292                $redir = $this->linkRenderer->makeKnownLink(
1293                    $this->mRedirectedFrom,
1294                    null,
1295                    [],
1296                    [ 'redirect' => 'no' ]
1297                );
1298
1299                $outputPage->addSubtitle( "<span class=\"mw-redirectedfrom\">" .
1300                    $context->msg( 'redirectedfrom' )->rawParams( $redir )->parse()
1301                . "</span>" );
1302
1303                // Add the script to update the displayed URL and
1304                // set the fragment if one was specified in the redirect
1305                $outputPage->addJsConfigVars( [
1306                    'wgInternalRedirectTargetUrl' => $redirectTargetUrl,
1307                ] );
1308                $outputPage->addModules( 'mediawiki.action.view.redirect' );
1309
1310                // Add a <link rel="canonical"> tag
1311                $outputPage->setCanonicalUrl( $this->getTitle()->getCanonicalURL() );
1312
1313                // Tell the output object that the user arrived at this article through a redirect
1314                $outputPage->setRedirectedFrom( $this->mRedirectedFrom );
1315
1316                return true;
1317            }
1318        } elseif ( $rdfrom ) {
1319            // This is an externally redirected view, from some other wiki.
1320            // If it was reported from a trusted site, supply a backlink.
1321            if ( $redirectSources && preg_match( $redirectSources, $rdfrom ) ) {
1322                $redir = $this->linkRenderer->makeExternalLink( $rdfrom, $rdfrom, $this->getTitle() );
1323                $outputPage->addSubtitle( "<span class=\"mw-redirectedfrom\">" .
1324                    $context->msg( 'redirectedfrom' )->rawParams( $redir )->parse()
1325                . "</span>" );
1326
1327                // Add the script to update the displayed URL
1328                $outputPage->addJsConfigVars( [
1329                    'wgInternalRedirectTargetUrl' => $redirectTargetUrl,
1330                ] );
1331                $outputPage->addModules( 'mediawiki.action.view.redirect' );
1332
1333                return true;
1334            }
1335        }
1336
1337        return false;
1338    }
1339
1340    /**
1341     * Show a header specific to the namespace currently being viewed, such as
1342     * [[MediaWiki:Subjectpageheader]] on subject pages or
1343     * [[MediaWiki:Talkpageheader]] on talk pages.
1344     *
1345     * This function is used in Article::view().
1346     *
1347     * For the addition of subject page headers, see T151682.
1348     */
1349    public function showNamespaceHeader() {
1350        if (
1351            !$this->getTitle()->isTalkPage() &&
1352            $this->getTitle()->exists() &&
1353            !$this->getContext()->msg( 'subjectpageheader' )->isDisabled()
1354        ) {
1355            $this->getContext()->getOutput()->wrapWikiMsg(
1356                "<div class=\"mw-subjectpageheader\">\n$1\n</div>",
1357                [ 'subjectpageheader' ]
1358            );
1359        }
1360
1361        if (
1362            $this->getTitle()->isTalkPage() &&
1363            !$this->getContext()->msg( 'talkpageheader' )->isDisabled()
1364        ) {
1365            $this->getContext()->getOutput()->wrapWikiMsg(
1366                "<div class=\"mw-talkpageheader\">\n$1\n</div>",
1367                [ 'talkpageheader' ]
1368            );
1369        }
1370    }
1371
1372    /**
1373     * Show the footer section of an ordinary page view
1374     */
1375    public function showViewFooter() {
1376        # check if we're displaying a [[User talk:x.x.x.x]] anonymous talk page
1377        if ( $this->getTitle()->getNamespace() === NS_USER_TALK
1378            && IPUtils::isValid( $this->getTitle()->getText() )
1379        ) {
1380            $this->getContext()->getOutput()->addWikiMsg( 'anontalkpagetext' );
1381        }
1382
1383        // Show a footer allowing the user to patrol the shown revision or page if possible
1384        $patrolFooterShown = $this->showPatrolFooter();
1385
1386        $this->getHookRunner()->onArticleViewFooter( $this, $patrolFooterShown );
1387    }
1388
1389    /**
1390     * If patrol is possible, output a patrol UI box. This is called from the
1391     * footer section of ordinary page views. If patrol is not possible or not
1392     * desired, does nothing.
1393     *
1394     * Side effect: When the patrol link is build, this method will call
1395     * OutputPage::setPreventClickjacking(true) and load a JS module.
1396     *
1397     * @return bool
1398     */
1399    public function showPatrolFooter() {
1400        $context = $this->getContext();
1401        $mainConfig = $context->getConfig();
1402        $useNPPatrol = $mainConfig->get( MainConfigNames::UseNPPatrol );
1403        $useRCPatrol = $mainConfig->get( MainConfigNames::UseRCPatrol );
1404        $useFilePatrol = $mainConfig->get( MainConfigNames::UseFilePatrol );
1405        $fileMigrationStage = $mainConfig->get( MainConfigNames::FileSchemaMigrationStage );
1406        // Allow hooks to decide whether to not output this at all
1407        if ( !$this->getHookRunner()->onArticleShowPatrolFooter( $this ) ) {
1408            return false;
1409        }
1410
1411        $outputPage = $context->getOutput();
1412        $user = $context->getUser();
1413        $title = $this->getTitle();
1414        $rc = false;
1415
1416        if ( !$context->getAuthority()->probablyCan( 'patrol', $title )
1417            || !( $useRCPatrol || $useNPPatrol
1418                || ( $useFilePatrol && $title->inNamespace( NS_FILE ) ) )
1419        ) {
1420            // Patrolling is disabled or the user isn't allowed to
1421            return false;
1422        }
1423
1424        if ( $this->mRevisionRecord
1425            && !RecentChange::isInRCLifespan( $this->mRevisionRecord->getTimestamp(), 21600 )
1426        ) {
1427            // The latest revision is already older than what could be in the RC table
1428            // 6h tolerance because the RC might not be cleaned out regularly
1429            return false;
1430        }
1431
1432        // Check for cached results
1433        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1434        $key = $cache->makeKey( 'unpatrollable-page', $title->getArticleID() );
1435        if ( $cache->get( $key ) ) {
1436            return false;
1437        }
1438
1439        $dbr = $this->dbProvider->getReplicaDatabase();
1440        $oldestRevisionRow = $dbr->newSelectQueryBuilder()
1441            ->select( [ 'rev_id', 'rev_timestamp' ] )
1442            ->from( 'revision' )
1443            ->where( [ 'rev_page' => $title->getArticleID() ] )
1444            ->orderBy( [ 'rev_timestamp', 'rev_id' ] )
1445            ->caller( __METHOD__ )->fetchRow();
1446        $oldestRevisionTimestamp = $oldestRevisionRow ? $oldestRevisionRow->rev_timestamp : false;
1447
1448        // New page patrol: Get the timestamp of the oldest revision which
1449        // the revision table holds for the given page. Then we look
1450        // whether it's within the RC lifespan and if it is, we try
1451        // to get the recentchanges row belonging to that entry.
1452        $recentPageCreation = false;
1453        if ( $oldestRevisionTimestamp
1454            && RecentChange::isInRCLifespan( $oldestRevisionTimestamp, 21600 )
1455        ) {
1456            // 6h tolerance because the RC might not be cleaned out regularly
1457            $recentPageCreation = true;
1458            $rc = $this->recentChangeLookup->getRecentChangeByConds(
1459                [
1460                    'rc_this_oldid' => intval( $oldestRevisionRow->rev_id ),
1461                    // Avoid selecting a categorization entry
1462                    'rc_source' => RecentChange::SRC_NEW,
1463                ],
1464                __METHOD__
1465            );
1466            if ( $rc ) {
1467                // Use generic patrol message for new pages
1468                $markPatrolledMsg = $context->msg( 'markaspatrolledtext' );
1469            }
1470        }
1471
1472        // File patrol: Get the timestamp of the latest upload for this page,
1473        // check whether it is within the RC lifespan and if it is, we try
1474        // to get the recentchanges row belonging to that entry
1475        // (with rc_source = SRC_LOG, rc_log_type = upload).
1476        $recentFileUpload = false;
1477        if ( ( !$rc || $rc->getAttribute( 'rc_patrolled' ) ) && $useFilePatrol
1478            && $title->getNamespace() === NS_FILE ) {
1479            // Retrieve timestamp from the current file (latest upload)
1480            if ( $fileMigrationStage & SCHEMA_COMPAT_READ_OLD ) {
1481                $newestUploadTimestamp = $dbr->newSelectQueryBuilder()
1482                    ->select( 'img_timestamp' )
1483                    ->from( 'image' )
1484                    ->where( [ 'img_name' => $title->getDBkey() ] )
1485                    ->caller( __METHOD__ )->fetchField();
1486            } else {
1487                $newestUploadTimestamp = $dbr->newSelectQueryBuilder()
1488                    ->select( 'fr_timestamp' )
1489                    ->from( 'file' )
1490                    ->join( 'filerevision', null, 'file_latest = fr_id' )
1491                    ->where( [ 'file_name' => $title->getDBkey() ] )
1492                    ->caller( __METHOD__ )->fetchField();
1493            }
1494
1495            if ( $newestUploadTimestamp
1496                && RecentChange::isInRCLifespan( $newestUploadTimestamp, 21600 )
1497            ) {
1498                // 6h tolerance because the RC might not be cleaned out regularly
1499                $recentFileUpload = true;
1500                $rc = $this->recentChangeLookup->getRecentChangeByConds(
1501                    [
1502                        'rc_source' => RecentChange::SRC_LOG,
1503                        'rc_log_type' => 'upload',
1504                        'rc_timestamp' => $newestUploadTimestamp,
1505                        'rc_namespace' => NS_FILE,
1506                        'rc_cur_id' => $title->getArticleID()
1507                    ],
1508                    __METHOD__
1509                );
1510                if ( $rc ) {
1511                    // Use patrol message specific to files
1512                    $markPatrolledMsg = $context->msg( 'markaspatrolledtext-file' );
1513                }
1514            }
1515        }
1516
1517        if ( !$recentPageCreation && !$recentFileUpload ) {
1518            // Page creation and latest upload (for files) is too old to be in RC
1519
1520            // We definitely can't patrol so cache the information
1521            // When a new file version is uploaded, the cache is cleared
1522            $cache->set( $key, '1' );
1523
1524            return false;
1525        }
1526
1527        if ( !$rc ) {
1528            // Don't cache: This can be hit if the page gets accessed very fast after
1529            // its creation / latest upload or in case we have high replica DB lag. In case
1530            // the revision is too old, we will already return above.
1531            return false;
1532        }
1533
1534        if ( $rc->getAttribute( 'rc_patrolled' ) ) {
1535            // Patrolled RC entry around
1536
1537            // Cache the information we gathered above in case we can't patrol
1538            // Don't cache in case we can patrol as this could change
1539            $cache->set( $key, '1' );
1540
1541            return false;
1542        }
1543
1544        if ( $rc->getPerformerIdentity()->equals( $user ) ) {
1545            // Don't show a patrol link for own creations/uploads. If the user could
1546            // patrol them, they already would be patrolled
1547            return false;
1548        }
1549
1550        $outputPage->getMetadata()->setPreventClickjacking( true );
1551        $outputPage->addModules( 'mediawiki.misc-authed-curate' );
1552
1553        $link = $this->linkRenderer->makeKnownLink(
1554            $title,
1555            new HtmlArmor( '<button class="cdx-button cdx-button--action-progressive">'
1556                // @phan-suppress-next-line PhanPossiblyUndeclaredVariable $markPatrolledMsg is always set
1557                . $markPatrolledMsg->escaped() . '</button>' ),
1558            [],
1559            [
1560                'action' => 'markpatrolled',
1561                'rcid' => $rc->getAttribute( 'rc_id' ),
1562            ]
1563        );
1564
1565        $outputPage->addModuleStyles( 'mediawiki.action.styles' );
1566        $outputPage->addHTML( "<div class='patrollink' data-mw-interface>$link</div>" );
1567
1568        return true;
1569    }
1570
1571    /**
1572     * Purge the cache used to check if it is worth showing the patrol footer
1573     * For example, it is done during re-uploads when file patrol is used.
1574     * @param int $articleID ID of the article to purge
1575     * @since 1.27
1576     */
1577    public static function purgePatrolFooterCache( $articleID ) {
1578        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1579        $cache->delete( $cache->makeKey( 'unpatrollable-page', $articleID ) );
1580    }
1581
1582    /**
1583     * Show the error text for a missing article. For articles in the MediaWiki
1584     * namespace, show the default message text. To be called from Article::view().
1585     */
1586    public function showMissingArticle() {
1587        $context = $this->getContext();
1588        $send404Code = $context->getConfig()->get( MainConfigNames::Send404Code );
1589
1590        $outputPage = $context->getOutput();
1591        // Whether the page is a root user page of an existing user (but not a subpage)
1592        $validUserPage = false;
1593
1594        $title = $this->getTitle();
1595
1596        $services = MediaWikiServices::getInstance();
1597
1598        $contextUser = $context->getUser();
1599
1600        # Indicate to client-side JS (Visual Editor in particular) whether
1601        # Parsoid would be used to render this article if created
1602        if ( $this->getParserOptions()->getUseParsoid() ) {
1603            $outputPage->addJsConfigVars(
1604                'wgParsoidHtmlVersion',
1605                Parsoid::defaultHTMLVersion()
1606            );
1607        }
1608
1609        # Show info in user (talk) namespace. Does the user exist? Are they blocked?
1610        if ( $title->getNamespace() === NS_USER
1611            || $title->getNamespace() === NS_USER_TALK
1612        ) {
1613            $rootPart = $title->getRootText();
1614            $userFactory = $services->getUserFactory();
1615            $user = $userFactory->newFromNameOrIp( $rootPart );
1616
1617            if ( $user && $user->isRegistered() && $user->isHidden() &&
1618                !$context->getAuthority()->isAllowed( 'hideuser' )
1619            ) {
1620                // T120883 if the user is hidden and the viewer cannot see hidden
1621                // users, pretend like it does not exist at all.
1622                $user = false;
1623            }
1624
1625            if ( !( $user && $user->isRegistered() ) && !$this->userNameUtils->isIP( $rootPart ) ) {
1626                $this->addMessageBoxStyles( $outputPage );
1627                // User does not exist
1628                $outputPage->addHTML( Html::warningBox(
1629                    $context->msg( 'userpage-userdoesnotexist-view', wfEscapeWikiText( $rootPart ) )->parse(),
1630                    'mw-userpage-userdoesnotexist'
1631                ) );
1632
1633                // Show renameuser log extract
1634                LogEventsList::showLogExtract(
1635                    $outputPage,
1636                    'renameuser',
1637                    Title::makeTitleSafe( NS_USER, $rootPart ),
1638                    '',
1639                    [
1640                        'lim' => 10,
1641                        'showIfEmpty' => false,
1642                        'msgKey' => [ 'renameuser-renamed-notice', $title->getBaseText() ]
1643                    ]
1644                );
1645            } else {
1646                $validUserPage = !$title->isSubpage();
1647
1648                $blockLogBox = LogEventsList::getBlockLogWarningBox(
1649                    $this->blockStore,
1650                    $services->getNamespaceInfo(),
1651                    $this->getContext(),
1652                    $this->linkRenderer,
1653                    $user,
1654                    $title
1655                );
1656                if ( $blockLogBox !== null ) {
1657                    $outputPage->addHTML( $blockLogBox );
1658                }
1659            }
1660        }
1661
1662        $this->getHookRunner()->onShowMissingArticle( $this );
1663
1664        # Show delete and move logs if there were any such events.
1665        # The logging query can DOS the site when bots/crawlers cause 404 floods,
1666        # so be careful showing this. 404 pages must be cheap as they are hard to cache.
1667        $dbCache = MediaWikiServices::getInstance()->getMainObjectStash();
1668        $key = $dbCache->makeKey( 'page-recent-delete', md5( $title->getPrefixedText() ) );
1669        $isRegistered = $contextUser->isRegistered();
1670        $sessionExists = $context->getRequest()->getSession()->isPersistent();
1671
1672        if ( $isRegistered || $dbCache->get( $key ) || $sessionExists ) {
1673            $logTypes = [ 'delete', 'move', 'protect', 'merge' ];
1674
1675            $dbr = $this->dbProvider->getReplicaDatabase();
1676
1677            $conds = [ $dbr->expr( 'log_action', '!=', 'revision' ) ];
1678            // Give extensions a chance to hide their (unrelated) log entries
1679            $this->getHookRunner()->onArticle__MissingArticleConditions( $conds, $logTypes );
1680            LogEventsList::showLogExtract(
1681                $outputPage,
1682                $logTypes,
1683                $title,
1684                '',
1685                [
1686                    'lim' => 10,
1687                    'conds' => $conds,
1688                    'showIfEmpty' => false,
1689                    'msgKey' => [ $isRegistered || $sessionExists
1690                        ? 'moveddeleted-notice'
1691                        : 'moveddeleted-notice-recent'
1692                    ]
1693                ]
1694            );
1695        }
1696
1697        if ( !$this->mPage->hasViewableContent() && $send404Code && !$validUserPage ) {
1698            // If there's no backing content, send a 404 Not Found
1699            // for better machine handling of broken links.
1700            $context->getRequest()->response()->statusHeader( 404 );
1701        }
1702
1703        // Also apply the robot policy for nonexisting pages (even if a 404 was used)
1704        $policy = $this->getRobotPolicy( 'view' );
1705        $outputPage->getMetadata()->setIndexPolicy( $policy['index'] );
1706        $outputPage->setFollowPolicy( $policy['follow'] );
1707
1708        $hookResult = $this->getHookRunner()->onBeforeDisplayNoArticleText( $this );
1709
1710        if ( !$hookResult ) {
1711            return;
1712        }
1713
1714        // Try shadow page
1715        $oldid = $this->getOldID();
1716        if ( !$oldid ) {
1717            $view = $this->shadowPageLoader->get( $this->getTitle() )?->getView( $this->getParserOptions() );
1718            if ( $view ) {
1719                $outputPage->addParserOutputContent(
1720                    $view->getParserOutput(), $view->getParserOptions() );
1721                return;
1722            }
1723        }
1724
1725        // Show error message
1726        if ( $oldid ) {
1727            $text = $this->getMissingRevisionMsg( $oldid )->plain();
1728        } elseif ( $context->getAuthority()->probablyCan( 'edit', $title ) ) {
1729            $message = $isRegistered ? 'noarticletext' : 'noarticletextanon';
1730            $text = $context->msg( $message )->plain();
1731        } else {
1732            $text = $context->msg( 'noarticletext-nopermission' )->plain();
1733        }
1734
1735        $dir = $context->getLanguage()->getDir();
1736        $lang = $context->getLanguage()->getHtmlCode();
1737        $outputPage->addWikiTextAsInterface( Html::openElement( 'div', [
1738            'class' => "noarticletext mw-content-$dir",
1739            'dir' => $dir,
1740            'lang' => $lang,
1741        ] ) . "\n$text\n</div>" );
1742    }
1743
1744    /**
1745     * Show error text for errors generated in Article::view().
1746     * @param string $errortext localized wikitext error message
1747     */
1748    private function showViewError( string $errortext ) {
1749        $outputPage = $this->getContext()->getOutput();
1750        $outputPage->setPageTitleMsg( $this->getContext()->msg( 'errorpagetitle' ) );
1751        $outputPage->disableClientCache();
1752        $outputPage->setRobotPolicy( 'noindex,nofollow' );
1753        $outputPage->clearHTML();
1754        $this->addMessageBoxStyles( $outputPage );
1755        $outputPage->addHTML( Html::errorBox( $outputPage->parseAsContent( $errortext ) ) );
1756    }
1757
1758    /**
1759     * If the revision requested for view is deleted, check permissions.
1760     * Send either an error message or a warning header to the output.
1761     *
1762     * @return bool True if the view is allowed, false if not.
1763     */
1764    public function showDeletedRevisionHeader() {
1765        if ( !$this->mRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
1766            // Not deleted
1767            return true;
1768        }
1769        $outputPage = $this->getContext()->getOutput();
1770        // Used in wikilinks, should not contain whitespaces
1771        $titleText = $this->getTitle()->getPrefixedURL();
1772        $this->addMessageBoxStyles( $outputPage );
1773        // If the user is not allowed to see it...
1774        if ( !$this->mRevisionRecord->userCan(
1775            RevisionRecord::DELETED_TEXT,
1776            $this->getContext()->getAuthority()
1777        ) ) {
1778            $outputPage->addHTML(
1779                Html::warningBox(
1780                    $outputPage->msg( 'rev-deleted-text-permission', $titleText )->parse(),
1781                    'plainlinks'
1782                )
1783            );
1784
1785            return false;
1786        // If the user needs to confirm that they want to see it...
1787        } elseif ( $this->getContext()->getRequest()->getInt( 'unhide' ) !== 1 ) {
1788            # Give explanation and add a link to view the revision...
1789            $oldid = intval( $this->getOldID() );
1790            $link = $this->getTitle()->getFullURL( "oldid={$oldid}&unhide=1" );
1791            $msg = $this->mRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ?
1792                'rev-suppressed-text-unhide' : 'rev-deleted-text-unhide';
1793            $outputPage->addHTML(
1794                Html::warningBox(
1795                    $outputPage->msg( $msg, $link )->parse(),
1796                    'plainlinks'
1797                )
1798            );
1799
1800            return false;
1801        // We are allowed to see...
1802        } else {
1803            $msg = $this->mRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED )
1804                ? [ 'rev-suppressed-text-view', $titleText ]
1805                : [ 'rev-deleted-text-view', $titleText ];
1806            $outputPage->addHTML(
1807                Html::warningBox(
1808                    $outputPage->msg( $msg[0], $msg[1] )->parse(),
1809                    'plainlinks'
1810                )
1811            );
1812
1813            return true;
1814        }
1815    }
1816
1817    private function addMessageBoxStyles( OutputPage $outputPage ) {
1818        $outputPage->addModuleStyles( [
1819            'mediawiki.codex.messagebox.styles',
1820        ] );
1821    }
1822
1823    /**
1824     * Generate the navigation links when browsing through an article revisions
1825     * It shows the information as:
1826     *   Revision as of \<date\>; view latest revision
1827     *   \<- Previous version | Next Version -\>
1828     *
1829     * @param int $oldid Revision ID of this article revision
1830     */
1831    public function setOldSubtitle( $oldid = 0 ) {
1832        if ( !$this->getHookRunner()->onDisplayOldSubtitle( $this, $oldid ) ) {
1833            return;
1834        }
1835
1836        $context = $this->getContext();
1837        $unhide = $context->getRequest()->getInt( 'unhide' ) === 1;
1838
1839        # Cascade unhide param in links for easy deletion browsing
1840        $extraParams = [];
1841        if ( $unhide ) {
1842            $extraParams['unhide'] = 1;
1843        }
1844
1845        if ( $this->mRevisionRecord && $this->mRevisionRecord->getId() === $oldid ) {
1846            $revisionRecord = $this->mRevisionRecord;
1847        } else {
1848            $revisionRecord = $this->revisionStore->getRevisionById( $oldid );
1849        }
1850        if ( !$revisionRecord ) {
1851            throw new LogicException( 'There should be a revision record at this point.' );
1852        }
1853
1854        $timestamp = $revisionRecord->getTimestamp();
1855
1856        $current = ( $oldid == $this->mPage->getLatest() );
1857        $language = $context->getLanguage();
1858        $user = $context->getUser();
1859
1860        $td = $language->userTimeAndDate( $timestamp, $user );
1861        $tddate = $language->userDate( $timestamp, $user );
1862        $tdtime = $language->userTime( $timestamp, $user );
1863
1864        # Show user links if allowed to see them. If hidden, then show them only if requested...
1865        $userlinks = Linker::revUserTools( $revisionRecord, !$unhide );
1866
1867        $infomsg = $current && !$context->msg( 'revision-info-current' )->isDisabled()
1868            ? 'revision-info-current'
1869            : 'revision-info';
1870
1871        $outputPage = $context->getOutput();
1872        $outputPage->addModuleStyles( [
1873            'mediawiki.action.styles',
1874            'mediawiki.interface.helpers.styles'
1875        ] );
1876
1877        $revisionUser = $revisionRecord->getUser();
1878        $revisionInfo = "<div id=\"mw-{$infomsg}\">" .
1879            $context->msg( $infomsg, $td )
1880                ->rawParams( $userlinks )
1881                ->params(
1882                    $revisionRecord->getId(),
1883                    $tddate,
1884                    $tdtime,
1885                    $revisionUser ? $revisionUser->getName() : ''
1886                )
1887                ->rawParams( $this->commentFormatter->formatRevision(
1888                    $revisionRecord,
1889                    $user,
1890                    true,
1891                    !$unhide
1892                ) )
1893                ->parse() .
1894            "</div>";
1895
1896        $lnk = $current
1897            ? $context->msg( 'currentrevisionlink' )->escaped()
1898            : $this->linkRenderer->makeKnownLink(
1899                $this->getTitle(),
1900                $context->msg( 'currentrevisionlink' )->text(),
1901                [],
1902                $extraParams
1903            );
1904        $curdiff = $current
1905            ? $context->msg( 'diff' )->escaped()
1906            : $this->linkRenderer->makeKnownLink(
1907                $this->getTitle(),
1908                $context->msg( 'diff' )->text(),
1909                [],
1910                [
1911                    'diff' => 'cur',
1912                    'oldid' => $oldid
1913                ] + $extraParams
1914            );
1915        $prevExist = (bool)$this->revisionStore->getPreviousRevision( $revisionRecord );
1916        $prevlink = $prevExist
1917            ? $this->linkRenderer->makeKnownLink(
1918                $this->getTitle(),
1919                $context->msg( 'previousrevision' )->text(),
1920                [],
1921                [
1922                    'direction' => 'prev',
1923                    'oldid' => $oldid
1924                ] + $extraParams
1925            )
1926            : $context->msg( 'previousrevision' )->escaped();
1927        $prevdiff = $prevExist
1928            ? $this->linkRenderer->makeKnownLink(
1929                $this->getTitle(),
1930                $context->msg( 'diff' )->text(),
1931                [],
1932                [
1933                    'diff' => 'prev',
1934                    'oldid' => $oldid
1935                ] + $extraParams
1936            )
1937            : $context->msg( 'diff' )->escaped();
1938        $nextlink = $current
1939            ? $context->msg( 'nextrevision' )->escaped()
1940            : $this->linkRenderer->makeKnownLink(
1941                $this->getTitle(),
1942                $context->msg( 'nextrevision' )->text(),
1943                [],
1944                [
1945                    'direction' => 'next',
1946                    'oldid' => $oldid
1947                ] + $extraParams
1948            );
1949        $nextdiff = $current
1950            ? $context->msg( 'diff' )->escaped()
1951            : $this->linkRenderer->makeKnownLink(
1952                $this->getTitle(),
1953                $context->msg( 'diff' )->text(),
1954                [],
1955                [
1956                    'diff' => 'next',
1957                    'oldid' => $oldid
1958                ] + $extraParams
1959            );
1960
1961        $cdel = Linker::getRevDeleteLink(
1962            $context->getAuthority(),
1963            $revisionRecord,
1964            $this->getTitle()
1965        );
1966        if ( $cdel !== '' ) {
1967            $cdel .= ' ';
1968        }
1969
1970        // the outer div is need for styling the revision info and nav in MobileFrontend
1971        $this->addMessageBoxStyles( $outputPage );
1972        $outputPage->addSubtitle(
1973            Html::warningBox(
1974                $revisionInfo .
1975                "<div id=\"mw-revision-nav\">" . $cdel .
1976                $context->msg( 'revision-nav' )->rawParams(
1977                    $prevdiff, $prevlink, $lnk, $curdiff, $nextlink, $nextdiff
1978                )->escaped() . "</div>",
1979                'mw-revision'
1980            )
1981        );
1982    }
1983
1984    /**
1985     * Adds help link with an icon via page indicators.
1986     * Link target can be overridden by a local message containing a wikilink:
1987     * the message key is: 'namespace-' + namespace number + '-helppage'.
1988     * @param string $to Target MediaWiki.org page title or encoded URL.
1989     * @param bool $overrideBaseUrl Whether $url is a full URL, to avoid MW.o.
1990     * @since 1.25
1991     */
1992    public function addHelpLink( $to, $overrideBaseUrl = false ) {
1993        $out = $this->getContext()->getOutput();
1994        $msg = $out->msg( 'namespace-' . $this->getTitle()->getNamespace() . '-helppage' );
1995
1996        if ( !$msg->isDisabled() ) {
1997            $title = Title::newFromText( $msg->plain() );
1998            if ( $title instanceof Title ) {
1999                $out->addHelpLink( $title->getLocalURL(), true );
2000            }
2001        } else {
2002            $out->addHelpLink( $to, $overrideBaseUrl );
2003        }
2004    }
2005
2006    /**
2007     * Handle action=render
2008     */
2009    public function render() {
2010        $this->getContext()->getRequest()->response()->header( 'X-Robots-Tag: noindex' );
2011        $this->getContext()->getOutput()->setArticleBodyOnly( true );
2012        // We later set suppress section edit links based on this; also used by ImagePage
2013        $this->viewIsRenderAction = true;
2014        $this->view();
2015    }
2016
2017    /**
2018     * action=protect handler
2019     */
2020    public function protect() {
2021        $form = new ProtectionForm( $this );
2022        $form->execute();
2023    }
2024
2025    /**
2026     * action=unprotect handler (alias)
2027     */
2028    public function unprotect() {
2029        $this->protect();
2030    }
2031
2032    /* Caching functions */
2033
2034    /**
2035     * checkLastModified returns true if it has taken care of all
2036     * output to the client that is necessary for this request.
2037     * (that is, it has sent a cached version of the page)
2038     *
2039     * @return bool True if cached version send, false otherwise
2040     */
2041    protected function tryFileCache() {
2042        static $called = false;
2043
2044        if ( $called ) {
2045            wfDebug( "Article::tryFileCache(): called twice!?" );
2046            return false;
2047        }
2048
2049        $called = true;
2050        if ( $this->isFileCacheable() ) {
2051            $cache = new HTMLFileCache( $this->getTitle(), 'view' );
2052            if ( $cache->isCacheGood( $this->mPage->getTouched() ) ) {
2053                wfDebug( "Article::tryFileCache(): about to load file" );
2054                $cache->loadFromFileCache( $this->getContext() );
2055                return true;
2056            } else {
2057                wfDebug( "Article::tryFileCache(): starting buffer" );
2058                ob_start( $cache->saveToFileCache( ... ) );
2059            }
2060        } else {
2061            wfDebug( "Article::tryFileCache(): not cacheable" );
2062        }
2063
2064        return false;
2065    }
2066
2067    /**
2068     * Check if the page can be cached
2069     * @param int $mode One of the HTMLFileCache::MODE_* constants (since 1.28)
2070     * @return bool
2071     */
2072    public function isFileCacheable( $mode = HTMLFileCache::MODE_NORMAL ) {
2073        $cacheable = false;
2074
2075        if ( HTMLFileCache::useFileCache( $this->getContext(), $mode ) ) {
2076            $cacheable = $this->mPage->getId()
2077                && !$this->mRedirectedFrom && !$this->getTitle()->isRedirect();
2078            // Extension may have reason to disable file caching on some pages.
2079            if ( $cacheable ) {
2080                $cacheable = $this->getHookRunner()->onIsFileCacheable( $this ) ?? false;
2081            }
2082        }
2083
2084        return $cacheable;
2085    }
2086
2087    /**
2088     * Lightweight method to get the parser output for a page, checking the parser cache
2089     * and so on. Doesn't consider most of the stuff that Article::view() is forced to
2090     * consider, so it's not appropriate to use there.
2091     *
2092     * @since 1.16 (r52326) for LiquidThreads
2093     *
2094     * @param int|null $oldid Revision ID or null
2095     * @param UserIdentity|null $user The relevant user
2096     * @param array $options Extra ParserOutputAccess options; see
2097     *   ParserOutputAccess::getParserOutput()
2098     * @return ParserOutput|false ParserOutput or false if the given revision ID is not found
2099     */
2100    public function getParserOutput( $oldid = null, ?UserIdentity $user = null, array $options = [] ) {
2101        $parserOptions = $this->getParserOptions( $oldid, $user );
2102        return $this->mPage->getParserOutput( $parserOptions, $oldid, options: $options );
2103    }
2104
2105    /**
2106     * Get parser options suitable for rendering the primary article wikitext
2107     * @return ParserOptions
2108     */
2109    public function getParserOptions( ?int $oldid = null, ?UserIdentity $user = null ) {
2110        $parserOptions =
2111            $this->mPage->makeParserOptions( $user ?? $this->getContext() );
2112        $parserOptions->setRenderReason( $oldid ? 'page_view_oldid' : 'page_view' );
2113        # Allow extensions to vary parser options used for article rendering
2114        $services = MediaWikiServices::getInstance();
2115        ( new HookRunner( $services->getHookContainer() ) )
2116            ->onArticleParserOptions( $this, $parserOptions );
2117
2118        return $parserOptions;
2119    }
2120
2121    /**
2122     * Sets the context this Article is executed in
2123     *
2124     * @param IContextSource $context
2125     * @since 1.18
2126     */
2127    public function setContext( $context ) {
2128        $this->mContext = $context;
2129    }
2130
2131    /**
2132     * Gets the context this Article is executed in
2133     *
2134     * @return IContextSource
2135     * @since 1.18
2136     */
2137    public function getContext(): IContextSource {
2138        if ( $this->mContext instanceof IContextSource ) {
2139            return $this->mContext;
2140        } else {
2141            wfDebug( __METHOD__ . " called and \$mContext is null. " .
2142                "Return RequestContext::getMain()" );
2143            return RequestContext::getMain();
2144        }
2145    }
2146
2147    /**
2148     * Call to WikiPage function for backwards compatibility.
2149     * @see ContentHandler::getActionOverrides
2150     * @return array
2151     */
2152    public function getActionOverrides() {
2153        return $this->mPage->getActionOverrides();
2154    }
2155
2156    private function getMissingRevisionMsg( int $oldid ): Message {
2157        // T251066: Try loading the revision from the archive table.
2158        // Show link to view it if it exists and the user has permission to view it.
2159        // (Ignore the given title, if any; look it up from the revision instead.)
2160        $context = $this->getContext();
2161        $revRecord = $this->archivedRevisionLookup->getArchivedRevisionRecord( null, $oldid );
2162        if (
2163            $revRecord &&
2164            $revRecord->userCan(
2165                RevisionRecord::DELETED_TEXT,
2166                $context->getAuthority()
2167            ) &&
2168            $context->getAuthority()->isAllowedAny( 'deletedtext', 'undelete' )
2169        ) {
2170            // You can see deleted content. So we know exactly what page is it on
2171            // and can render the message in that context regardless of what
2172            // title (if any) is given in URL params
2173            return $context->msg(
2174                'missing-revision-permission',
2175                $oldid,
2176                $revRecord->getTimestamp(),
2177                Title::newFromPageIdentity( $revRecord->getPage() )->getPrefixedURL()
2178            )->page( $revRecord->getPage() );
2179        }
2180
2181        if ( $context->getRequest()->getCheck( 'title' ) ) {
2182            // If you specify a title then link to the deletion log for that title
2183            return $context->msg( 'missing-revision', $oldid );
2184        } else {
2185            // Don't show the deletion log for the main page if you don't specify a title
2186            return $context->msg( 'missing-revision-nolog', $oldid );
2187        }
2188    }
2189
2190    private function usePostProcessingCache( ParserOptions $poptions ): bool {
2191        if ( $poptions->getUseParsoid() ) {
2192            return $this->parsoidPostprocCacheAvailable;
2193        }
2194        return $this->legacyPostprocCacheAvailable && $this->useLegacyPostprocCache;
2195    }
2196
2197    /**
2198     * By default, we do not use the postprocessing cache for legacy parses; however, we want to be able to
2199     * override this for some cases (e.g. legacy parses of DiscussionTools, as long as Parsoid Read Views is not
2200     * the default everywhere.)
2201     */
2202    public function setUseLegacyPostprocCache( bool $val = true ): void {
2203        $this->useLegacyPostprocCache = $val;
2204    }
2205}
2206
2207/** @deprecated class alias since 1.44 */
2208class_alias( Article::class, 'Article' );