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