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