Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 574
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
InfoAction
0.00% covered (danger)
0.00%
0 / 573
0.00% covered (danger)
0.00%
0 / 14
10100
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 requiresUnblock
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 requiresWrite
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 invalidateCache
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 onView
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
182
 makeHeader
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getRow
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 pageInfo
0.00% covered (danger)
0.00%
0 / 367
0.00% covered (danger)
0.00%
0 / 1
3540
 getNamespaceProtectionMessage
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
110
 pageCounts
0.00% covered (danger)
0.00%
0 / 95
0.00% covered (danger)
0.00%
0 / 1
30
 getPageTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCacheKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Displays information about a page.
4 *
5 * Copyright © 2011 Alexandre Emsenhuber
6 *
7 * @license GPL-2.0-or-later
8 * @file
9 * @ingroup Actions
10 */
11
12namespace MediaWiki\Actions;
13
14use MediaWiki\Cache\LinkBatchFactory;
15use MediaWiki\Category\Category;
16use MediaWiki\Content\ContentHandler;
17use MediaWiki\Context\IContextSource;
18use MediaWiki\Deferred\LinksUpdate\TemplateLinksTable;
19use MediaWiki\EditPage\TemplatesOnThisPageFormatter;
20use MediaWiki\FileRepo\RepoGroup;
21use MediaWiki\Html\Html;
22use MediaWiki\Language\Language;
23use MediaWiki\Languages\LanguageNameUtils;
24use MediaWiki\Linker\Linker;
25use MediaWiki\Linker\LinkRenderer;
26use MediaWiki\Linker\LinksMigration;
27use MediaWiki\MainConfigNames;
28use MediaWiki\MediaWikiServices;
29use MediaWiki\Message\Message;
30use MediaWiki\Page\Article;
31use MediaWiki\Page\PageIdentity;
32use MediaWiki\Page\PageProps;
33use MediaWiki\Page\RedirectLookup;
34use MediaWiki\Parser\MagicWordFactory;
35use MediaWiki\Parser\ParserOutput;
36use MediaWiki\Parser\Sanitizer;
37use MediaWiki\Permissions\RestrictionStore;
38use MediaWiki\Revision\RevisionLookup;
39use MediaWiki\Revision\RevisionRecord;
40use MediaWiki\SpecialPage\SpecialPage;
41use MediaWiki\Title\NamespaceInfo;
42use MediaWiki\Title\Title;
43use MediaWiki\User\UserFactory;
44use MediaWiki\Watchlist\WatchedItemStoreInterface;
45use Wikimedia\ObjectCache\WANObjectCache;
46use Wikimedia\Rdbms\Database;
47use Wikimedia\Rdbms\IConnectionProvider;
48use Wikimedia\Rdbms\IDBAccessObject;
49use Wikimedia\Rdbms\IExpression;
50use Wikimedia\Rdbms\LikeValue;
51
52/**
53 * Displays information about a page.
54 *
55 * @ingroup Actions
56 */
57class InfoAction extends FormlessAction {
58    private const VERSION = 1;
59
60    private Language $contentLanguage;
61    private LanguageNameUtils $languageNameUtils;
62    private LinkBatchFactory $linkBatchFactory;
63    private LinkRenderer $linkRenderer;
64    private IConnectionProvider $dbProvider;
65    private MagicWordFactory $magicWordFactory;
66    private NamespaceInfo $namespaceInfo;
67    private PageProps $pageProps;
68    private RepoGroup $repoGroup;
69    private RevisionLookup $revisionLookup;
70    private WANObjectCache $wanObjectCache;
71    private WatchedItemStoreInterface $watchedItemStore;
72    private RedirectLookup $redirectLookup;
73    private RestrictionStore $restrictionStore;
74    private LinksMigration $linksMigration;
75    private UserFactory $userFactory;
76
77    public function __construct(
78        Article $article,
79        IContextSource $context,
80        Language $contentLanguage,
81        LanguageNameUtils $languageNameUtils,
82        LinkBatchFactory $linkBatchFactory,
83        LinkRenderer $linkRenderer,
84        IConnectionProvider $dbProvider,
85        MagicWordFactory $magicWordFactory,
86        NamespaceInfo $namespaceInfo,
87        PageProps $pageProps,
88        RepoGroup $repoGroup,
89        RevisionLookup $revisionLookup,
90        WANObjectCache $wanObjectCache,
91        WatchedItemStoreInterface $watchedItemStore,
92        RedirectLookup $redirectLookup,
93        RestrictionStore $restrictionStore,
94        LinksMigration $linksMigration,
95        UserFactory $userFactory
96    ) {
97        parent::__construct( $article, $context );
98        $this->contentLanguage = $contentLanguage;
99        $this->languageNameUtils = $languageNameUtils;
100        $this->linkBatchFactory = $linkBatchFactory;
101        $this->linkRenderer = $linkRenderer;
102        $this->dbProvider = $dbProvider;
103        $this->magicWordFactory = $magicWordFactory;
104        $this->namespaceInfo = $namespaceInfo;
105        $this->pageProps = $pageProps;
106        $this->repoGroup = $repoGroup;
107        $this->revisionLookup = $revisionLookup;
108        $this->wanObjectCache = $wanObjectCache;
109        $this->watchedItemStore = $watchedItemStore;
110        $this->redirectLookup = $redirectLookup;
111        $this->restrictionStore = $restrictionStore;
112        $this->linksMigration = $linksMigration;
113        $this->userFactory = $userFactory;
114    }
115
116    /** @inheritDoc */
117    public function getName() {
118        return 'info';
119    }
120
121    /** @inheritDoc */
122    public function requiresUnblock() {
123        return false;
124    }
125
126    /** @inheritDoc */
127    public function requiresWrite() {
128        return false;
129    }
130
131    /**
132     * Clear the info cache for a given Title.
133     *
134     * @since 1.22
135     * @param PageIdentity $page Title to clear cache for
136     * @param int|null $revid Revision id to clear
137     */
138    public static function invalidateCache( PageIdentity $page, $revid = null ) {
139        $services = MediaWikiServices::getInstance();
140        if ( $revid === null ) {
141            $revision = $services->getRevisionLookup()
142                ->getRevisionByTitle( $page, 0, IDBAccessObject::READ_LATEST );
143            $revid = $revision ? $revision->getId() : 0;
144        }
145        $cache = $services->getMainWANObjectCache();
146        $key = self::getCacheKey( $cache, $page, $revid ?? 0 );
147        $cache->delete( $key );
148    }
149
150    /**
151     * Shows page information on GET request.
152     *
153     * @return string Page information that will be added to the output
154     */
155    public function onView() {
156        $this->getOutput()->addModuleStyles( [
157            'mediawiki.interface.helpers.styles',
158            'mediawiki.action.styles',
159        ] );
160
161        // "Help" button
162        $this->addHelpLink( 'Page information' );
163
164        // Validate revision
165        $oldid = $this->getArticle()->getOldID();
166        if ( $oldid ) {
167            $revRecord = $this->getArticle()->fetchRevisionRecord();
168
169            if ( !$revRecord ) {
170                return $this->msg( 'missing-revision', $oldid )->parse();
171            } elseif ( !$revRecord->isCurrent() ) {
172                return $this->msg( 'pageinfo-not-current' )->plain();
173            }
174        }
175
176        // Page header
177        $msg = $this->msg( 'pageinfo-header' );
178        $content = $msg->isDisabled() ? '' : $msg->parse();
179
180        // Get page information
181        $pageInfo = $this->pageInfo();
182
183        // Allow extensions to add additional information
184        $this->getHookRunner()->onInfoAction( $this->getContext(), $pageInfo );
185
186        // Render page information
187        foreach ( $pageInfo as $header => $infoTable ) {
188            // Messages:
189            // pageinfo-header-basic, pageinfo-header-edits, pageinfo-header-restrictions,
190            // pageinfo-header-properties, pageinfo-category-info
191            $content .= $this->makeHeader(
192                $this->msg( "pageinfo-$header" )->text(),
193                "mw-pageinfo-$header"
194            ) . "\n";
195            $rows = '';
196            $below = "";
197            foreach ( $infoTable as $infoRow ) {
198                if ( $infoRow[0] == "below" ) {
199                    $below = $infoRow[1] . "\n";
200                    continue;
201                }
202                $name = ( $infoRow[0] instanceof Message ) ? $infoRow[0]->escaped() : $infoRow[0];
203                $value = ( $infoRow[1] instanceof Message ) ? $infoRow[1]->escaped() : $infoRow[1];
204                $id = ( $infoRow[0] instanceof Message ) ? $infoRow[0]->getKey() : null;
205                $rows .= $this->getRow( $name, $value, $id ) . "\n";
206            }
207            if ( $rows !== '' ) {
208                $content .= Html::rawElement( 'table', [ 'class' => 'wikitable mw-page-info' ],
209                    "\n" . $rows );
210            }
211            $content .= "\n" . $below;
212        }
213
214        // Page footer
215        if ( !$this->msg( 'pageinfo-footer' )->isDisabled() ) {
216            $content .= $this->msg( 'pageinfo-footer' )->parse();
217        }
218
219        return $content;
220    }
221
222    /**
223     * Creates a header that can be added to the output.
224     *
225     * @param string $header The header text.
226     * @param string $canonicalId
227     * @return string The HTML.
228     */
229    private function makeHeader( $header, $canonicalId ) {
230        return Html::rawElement(
231            'h2',
232            [ 'id' => Sanitizer::escapeIdForAttribute( $header ) ],
233            Html::element(
234                'span',
235                [ 'id' => Sanitizer::escapeIdForAttribute( $canonicalId ) ],
236                ''
237            ) .
238            htmlspecialchars( $header )
239        );
240    }
241
242    /**
243     * @param string $name The name of the row
244     * @param string $value The value of the row
245     * @param string|null $id The ID to use for the 'tr' element
246     * @param-taint $id none
247     * @return string HTML
248     */
249    private function getRow( $name, $value, $id ) {
250        return Html::rawElement(
251                'tr',
252                [
253                    'id' => $id === null ? null : 'mw-' . $id,
254                    'style' => 'vertical-align: top;',
255                ],
256                Html::rawElement( 'td', [], $name ) .
257                    Html::rawElement( 'td', [], $value )
258            );
259    }
260
261    /**
262     * Returns an array of info groups (will be rendered as tables), keyed by group ID.
263     * Group IDs are arbitrary and used so that extensions may add additional information in
264     * arbitrary positions (and as message keys for section headers for the tables, prefixed
265     * with 'pageinfo-').
266     * Each info group is a non-associative array of info items (rendered as table rows).
267     * Each info item is an array with two elements: the first describes the type of
268     * information, the second the value for the current page. Both can be strings (will be
269     * interpreted as raw HTML) or messages (will be interpreted as plain text and escaped).
270     *
271     * @return array
272     * @phan-return array<string, list<array{0:string|Message, 1:string|Message}>>
273     */
274    private function pageInfo() {
275        $user = $this->getUser();
276        $lang = $this->getLanguage();
277        $title = $this->getTitle();
278        $id = $title->getArticleID();
279        $config = $this->context->getConfig();
280        $linkRenderer = $this->linkRenderer;
281
282        $pageCounts = $this->pageCounts();
283
284        $pageProperties = $this->pageProps->getAllProperties( $title )[$id] ?? [];
285
286        // Basic information
287        $pageInfo = [];
288        $pageInfo['header-basic'] = [];
289
290        // Display title
291        $displayTitle = $pageProperties['displaytitle'] ??
292            htmlspecialchars( $title->getPrefixedText(), ENT_NOQUOTES );
293
294        $pageInfo['header-basic'][] = [
295            $this->msg( 'pageinfo-display-title' ),
296            $displayTitle
297        ];
298
299        // Is it a redirect? If so, where to?
300        $redirectTarget = $this->redirectLookup->getRedirectTarget( $this->getWikiPage() );
301        if ( $redirectTarget !== null ) {
302            $pageInfo['header-basic'][] = [
303                $this->msg( 'pageinfo-redirectsto' ),
304                $linkRenderer->makeLink( $redirectTarget ) .
305                $this->msg( 'word-separator' )->escaped() .
306                $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
307                    $redirectTarget,
308                    $this->msg( 'pageinfo-redirectsto-info' )->text(),
309                    [],
310                    [ 'action' => 'info' ]
311                ) )->escaped()
312            ];
313        }
314
315        // Default sort key
316        $sortKey = $pageProperties['defaultsort'] ?? $title->getCategorySortkey();
317        $pageInfo['header-basic'][] = [
318            $this->msg( 'pageinfo-default-sort' ),
319            htmlspecialchars( $sortKey )
320        ];
321
322        // Page length (in bytes)
323        $pageInfo['header-basic'][] = [
324            $this->msg( 'pageinfo-length' ),
325            $lang->formatNum( $title->getLength() )
326        ];
327
328        // Page namespace
329        $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-namespace-id' ), $title->getNamespace() ];
330        $pageNamespace = $title->getNsText();
331        if ( $pageNamespace ) {
332            $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-namespace' ), $pageNamespace ];
333        }
334
335        // Page ID (number not localised, as it's a database ID)
336        $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-article-id' ), $id ];
337
338        // Language in which the page content is (supposed to be) written
339        $pageLang = $title->getPageLanguage()->getCode();
340
341        $pageLangHtml = $pageLang . ' - ' .
342            $this->languageNameUtils->getLanguageName( $pageLang, $lang->getCode() );
343        // Link to Special:PageLanguage with pre-filled page title if user has permissions
344        if ( $config->get( MainConfigNames::PageLanguageUseDB )
345            && $this->getAuthority()->probablyCan( 'pagelang', $title )
346        ) {
347            $pageLangHtml .= $this->msg( 'word-separator' )->escaped();
348            $pageLangHtml .= $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
349                SpecialPage::getTitleValueFor( 'PageLanguage', $title->getPrefixedText() ),
350                $this->msg( 'pageinfo-language-change' )->text()
351            ) )->escaped();
352        }
353
354        $pageInfo['header-basic'][] = [
355            $this->msg( 'pageinfo-language' )->escaped(),
356            $pageLangHtml
357        ];
358
359        // Content model of the page
360        $modelHtml = htmlspecialchars( ContentHandler::getLocalizedName( $title->getContentModel() ) );
361        // If the user can change it, add a link to Special:ChangeContentModel
362        if ( $this->getAuthority()->probablyCan( 'editcontentmodel', $title ) ) {
363            $modelHtml .= $this->msg( 'word-separator' )->escaped();
364            $modelHtml .= $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
365                SpecialPage::getTitleValueFor( 'ChangeContentModel', $title->getPrefixedText() ),
366                $this->msg( 'pageinfo-content-model-change' )->text()
367            ) )->escaped();
368        }
369
370        $pageInfo['header-basic'][] = [
371            $this->msg( 'pageinfo-content-model' ),
372            $modelHtml
373        ];
374
375        if ( $title->inNamespace( NS_USER ) ) {
376            $pageUser = $this->userFactory->newFromName( $title->getRootText() );
377            if ( $pageUser && $pageUser->getId() && !$pageUser->isHidden() ) {
378                $pageInfo['header-basic'][] = [
379                    $this->msg( 'pageinfo-user-id' ),
380                    $pageUser->getId()
381                ];
382            }
383        }
384
385        // Search engine status
386        $parserOutput = new ParserOutput();
387        if ( isset( $pageProperties['noindex'] ) ) {
388            $parserOutput->setIndexPolicy( 'noindex' );
389        }
390        if ( isset( $pageProperties['index'] ) ) {
391            $parserOutput->setIndexPolicy( 'index' );
392        }
393
394        // Use robot policy logic
395        $policy = $this->getArticle()->getRobotPolicy( 'view', $parserOutput );
396        $pageInfo['header-basic'][] = [
397            // Messages: pageinfo-robot-index, pageinfo-robot-noindex
398            $this->msg( 'pageinfo-robot-policy' ),
399            $this->msg( "pageinfo-robot-{$policy['index']}" )
400        ];
401
402        $unwatchedPageThreshold = $config->get( MainConfigNames::UnwatchedPageThreshold );
403        if ( $this->getAuthority()->isAllowed( 'unwatchedpages' ) ||
404            ( $unwatchedPageThreshold !== false &&
405                $pageCounts['watchers'] >= $unwatchedPageThreshold )
406        ) {
407            // Number of page watchers
408            $pageInfo['header-basic'][] = [
409                $this->msg( 'pageinfo-watchers' ),
410                $lang->formatNum( $pageCounts['watchers'] )
411            ];
412
413            $visiting = $pageCounts['visitingWatchers'] ?? null;
414            if ( $visiting !== null && $config->get( MainConfigNames::ShowUpdatedMarker ) ) {
415                if ( $visiting > $config->get( MainConfigNames::UnwatchedPageSecret ) ||
416                    $this->getAuthority()->isAllowed( 'unwatchedpages' )
417                ) {
418                    $value = $lang->formatNum( $visiting );
419                } else {
420                    $value = $this->msg( 'pageinfo-few-visiting-watchers' );
421                }
422                $pageInfo['header-basic'][] = [
423                    $this->msg( 'pageinfo-visiting-watchers' )
424                        ->numParams( ceil( $config->get( MainConfigNames::WatchersMaxAge ) / 86400 ) ),
425                    $value
426                ];
427            }
428        } elseif ( $unwatchedPageThreshold !== false ) {
429            $pageInfo['header-basic'][] = [
430                $this->msg( 'pageinfo-watchers' ),
431                $this->msg( 'pageinfo-few-watchers' )->numParams( $unwatchedPageThreshold )
432            ];
433        }
434
435        // Redirects to this page
436        $whatLinksHere = SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedText() );
437        $pageInfo['header-basic'][] = [
438            $linkRenderer->makeLink(
439                $whatLinksHere,
440                $this->msg( 'pageinfo-redirects-name' )->text(),
441                [],
442                [
443                    'hidelinks' => 1,
444                    'hidetrans' => 1,
445                    'hideimages' => $title->getNamespace() === NS_FILE
446                ]
447            ),
448            $this->msg( 'pageinfo-redirects-value' )
449                ->numParams( count( $title->getRedirectsHere() ) )
450        ];
451
452        // Is it counted as a content page?
453        if ( $this->getWikiPage()->isCountable() ) {
454            $pageInfo['header-basic'][] = [
455                $this->msg( 'pageinfo-contentpage' ),
456                $this->msg( 'pageinfo-contentpage-yes' )
457            ];
458        }
459
460        // Subpages of this page, if subpages are enabled for the current NS
461        if ( $this->namespaceInfo->hasSubpages( $title->getNamespace() ) ) {
462            $prefixIndex = SpecialPage::getTitleFor(
463                'Prefixindex',
464                $title->getPrefixedText() . '/'
465            );
466            $pageInfo['header-basic'][] = [
467                $linkRenderer->makeLink(
468                    $prefixIndex,
469                    $this->msg( 'pageinfo-subpages-name' )->text()
470                ),
471                // $wgNamespacesWithSubpages can be changed and this can be unset (T340749)
472                isset( $pageCounts['subpages'] )
473                    ? $this->msg( 'pageinfo-subpages-value' )->numParams(
474                        $pageCounts['subpages']['total'],
475                        $pageCounts['subpages']['redirects'],
476                        $pageCounts['subpages']['nonredirects']
477                    ) : $this->msg( 'pageinfo-subpages-value-unknown' )->rawParams(
478                        $linkRenderer->makeKnownLink(
479                            $title, $this->msg( 'purge' )->text(), [], [ 'action' => 'purge' ] )
480                    )
481            ];
482        }
483
484        if ( $title->inNamespace( NS_CATEGORY ) ) {
485            $category = Category::newFromTitle( $title );
486
487            $allCount = $category->getMemberCount();
488            $subcatCount = $category->getSubcatCount();
489            $fileCount = $category->getFileCount();
490            $pageCount = $category->getPageCount( Category::COUNT_CONTENT_PAGES );
491
492            $pageInfo['category-info'] = [
493                [
494                    $this->msg( 'pageinfo-category-total' ),
495                    $lang->formatNum( $allCount )
496                ],
497                [
498                    $this->msg( 'pageinfo-category-pages' ),
499                    $lang->formatNum( $pageCount )
500                ],
501                [
502                    $this->msg( 'pageinfo-category-subcats' ),
503                    $lang->formatNum( $subcatCount )
504                ],
505                [
506                    $this->msg( 'pageinfo-category-files' ),
507                    $lang->formatNum( $fileCount )
508                ]
509            ];
510        }
511
512        // Display image SHA-1 value
513        if ( $title->inNamespace( NS_FILE ) ) {
514            $fileObj = $this->repoGroup->findFile( $title );
515            if ( $fileObj !== false ) {
516                // Convert the base-36 sha1 value obtained from database to base-16
517                $output = \Wikimedia\base_convert( $fileObj->getSha1(), 36, 16, 40 );
518                $pageInfo['header-basic'][] = [
519                    $this->msg( 'pageinfo-file-hash' ),
520                    $output
521                ];
522            }
523        }
524
525        // Page protection
526        $pageInfo['header-restrictions'] = [];
527
528        // Is this page affected by the cascading protection of something which includes it?
529        if ( $this->restrictionStore->isCascadeProtected( $title ) ) {
530            $cascadingFrom = '';
531            $sources = $this->restrictionStore->getCascadeProtectionSources( $title )[0];
532
533            foreach ( $sources as $sourcePageIdentity ) {
534                $cascadingFrom .= Html::rawElement(
535                    'li',
536                    [],
537                    $linkRenderer->makeKnownLink( $sourcePageIdentity )
538                );
539            }
540
541            $cascadingFrom = Html::rawElement( 'ul', [], $cascadingFrom );
542            $pageInfo['header-restrictions'][] = [
543                $this->msg( 'pageinfo-protect-cascading-from' ),
544                $cascadingFrom
545            ];
546        }
547
548        // Is out protection set to cascade to other pages?
549        if ( $this->restrictionStore->areRestrictionsCascading( $title ) ) {
550            $pageInfo['header-restrictions'][] = [
551                $this->msg( 'pageinfo-protect-cascading' ),
552                $this->msg( 'pageinfo-protect-cascading-yes' )
553            ];
554        }
555
556        // Page protection
557        foreach ( $this->restrictionStore->listApplicableRestrictionTypes( $title ) as $restrictionType ) {
558            $protections = $this->restrictionStore->getRestrictions( $title, $restrictionType );
559
560            switch ( count( $protections ) ) {
561                case 0:
562                    $message = $this->getNamespaceProtectionMessage( $title ) ??
563                        // Allow all users by default
564                        $this->msg( 'protect-default' )->escaped();
565                    break;
566
567                case 1:
568                    // Messages: protect-level-autoconfirmed, protect-level-sysop
569                    $message = $this->msg( 'protect-level-' . $protections[0] );
570                    if ( !$message->isDisabled() ) {
571                        $message = $message->escaped();
572                        break;
573                    }
574                    // Intentional fall-through if message is disabled (or non-existent)
575
576                default:
577                    // Require "$1" permission
578                    $message = $this->msg( "protect-fallback", $lang->commaList( $protections ) )->parse();
579                    break;
580            }
581            $expiry = $this->restrictionStore->getRestrictionExpiry( $title, $restrictionType );
582            $formattedexpiry = $expiry === null ? '' : $this->msg(
583                'parentheses',
584                $lang->formatExpiry( $expiry, true, 'infinity', $user )
585            )->escaped();
586            $message .= $this->msg( 'word-separator' )->escaped() . $formattedexpiry;
587
588            // Messages: restriction-edit, restriction-move, restriction-create,
589            // restriction-upload
590            $pageInfo['header-restrictions'][] = [
591                $this->msg( "restriction-$restrictionType" ), $message
592            ];
593        }
594        $protectLog = SpecialPage::getTitleFor( 'Log' );
595        $pageInfo['header-restrictions'][] = [
596            'below',
597            $linkRenderer->makeKnownLink(
598                $protectLog,
599                $this->msg( 'pageinfo-view-protect-log' )->text(),
600                [],
601                [ 'type' => 'protect', 'page' => $title->getPrefixedText() ]
602            ),
603        ];
604
605        if ( !$this->getWikiPage()->exists() ) {
606            return $pageInfo;
607        }
608
609        // Edit history
610        $pageInfo['header-edits'] = [];
611
612        $firstRev = $this->revisionLookup->getFirstRevision( $this->getTitle() );
613        $lastRev = $this->getWikiPage()->getRevisionRecord();
614        $batch = $this->linkBatchFactory->newLinkBatch()
615            ->setCaller( __METHOD__ );
616        if ( $firstRev ) {
617            $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
618            if ( $firstRevUser ) {
619                $batch->addUser( $firstRevUser );
620            }
621        }
622
623        if ( $lastRev ) {
624            $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
625            if ( $lastRevUser ) {
626                $batch->addUser( $lastRevUser );
627            }
628        }
629
630        $batch->execute();
631
632        if ( $firstRev ) {
633            // Page creator
634            $firstRevUser = $firstRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
635            // Check if the username is available – it may have been suppressed, in
636            // which case use the invalid user name '[HIDDEN]' to get the wiki's
637            // default user gender.
638            $firstRevUserName = $firstRevUser ? $firstRevUser->getName() : '[HIDDEN]';
639            $pageInfo['header-edits'][] = [
640                $this->msg( 'pageinfo-firstuser', $firstRevUserName ),
641                Linker::revUserTools( $firstRev )
642            ];
643
644            // Date of page creation
645            $pageInfo['header-edits'][] = [
646                $this->msg( 'pageinfo-firsttime' ),
647                $linkRenderer->makeKnownLink(
648                    $title,
649                    $lang->userTimeAndDate( $firstRev->getTimestamp(), $user ),
650                    [],
651                    [ 'oldid' => $firstRev->getId() ]
652                )
653            ];
654        }
655
656        if ( $lastRev ) {
657            // Latest editor
658            $lastRevUser = $lastRev->getUser( RevisionRecord::FOR_THIS_USER, $user );
659            // Check if the username is available – it may have been suppressed, in
660            // which case use the invalid user name '[HIDDEN]' to get the wiki's
661            // default user gender.
662            $lastRevUserName = $lastRevUser ? $lastRevUser->getName() : '[HIDDEN]';
663            $pageInfo['header-edits'][] = [
664                $this->msg( 'pageinfo-lastuser', $lastRevUserName ),
665                Linker::revUserTools( $lastRev )
666            ];
667
668            // Date of latest edit
669            $pageInfo['header-edits'][] = [
670                $this->msg( 'pageinfo-lasttime' ),
671                $linkRenderer->makeKnownLink(
672                    $title,
673                    $lang->userTimeAndDate( $this->getWikiPage()->getTimestamp(), $user ),
674                    [],
675                    [ 'oldid' => $this->getWikiPage()->getLatest() ]
676                )
677            ];
678        }
679
680        // Total number of edits
681        $pageInfo['header-edits'][] = [
682            $this->msg( 'pageinfo-edits' ),
683            $lang->formatNum( $pageCounts['edits'] )
684        ];
685
686        // Total number of distinct authors
687        if ( $pageCounts['authors'] > 0 ) {
688            $pageInfo['header-edits'][] = [
689                $this->msg( 'pageinfo-authors' ),
690                $lang->formatNum( $pageCounts['authors'] )
691            ];
692        }
693
694        // Recent number of edits (within past 30 days)
695        $pageInfo['header-edits'][] = [
696            $this->msg(
697                'pageinfo-recent-edits',
698                $lang->formatDuration( $config->get( MainConfigNames::RCMaxAge ) )
699            ),
700            $lang->formatNum( $pageCounts['recent_edits'] )
701        ];
702
703        // Recent number of distinct authors
704        $pageInfo['header-edits'][] = [
705            $this->msg( 'pageinfo-recent-authors' ),
706            $lang->formatNum( $pageCounts['recent_authors'] )
707        ];
708
709        // Array of magic word IDs
710        $wordIDs = $this->magicWordFactory->getDoubleUnderscoreArray()->getNames();
711
712        // Array of IDs => localized magic words
713        $localizedWords = $this->contentLanguage->getMagicWords();
714
715        $listItems = [];
716        foreach ( $pageProperties as $property => $value ) {
717            if ( in_array( $property, $wordIDs ) ) {
718                $listItems[] = Html::element( 'li', [], $localizedWords[$property][1] );
719            }
720        }
721
722        $localizedList = Html::rawElement( 'ul', [], implode( '', $listItems ) );
723        $hiddenCategories = $this->getWikiPage()->getHiddenCategories();
724
725        if (
726            count( $listItems ) > 0 ||
727            count( $hiddenCategories ) > 0 ||
728            $pageCounts['transclusion']['from'] > 0 ||
729            $pageCounts['transclusion']['to'] > 0
730        ) {
731            $options = [ 'LIMIT' => $config->get( MainConfigNames::PageInfoTransclusionLimit ) ];
732            $transcludedTemplates = $title->getTemplateLinksFrom( $options );
733            if ( $config->get( MainConfigNames::MiserMode ) ) {
734                $transcludedTargets = [];
735            } else {
736                $transcludedTargets = $title->getTemplateLinksTo( $options );
737            }
738
739            // Page properties
740            $pageInfo['header-properties'] = [];
741
742            // Magic words
743            if ( count( $listItems ) > 0 ) {
744                $pageInfo['header-properties'][] = [
745                    $this->msg( 'pageinfo-magic-words' )->numParams( count( $listItems ) ),
746                    $localizedList
747                ];
748            }
749
750            // Hidden categories
751            if ( count( $hiddenCategories ) > 0 ) {
752                $pageInfo['header-properties'][] = [
753                    $this->msg( 'pageinfo-hidden-categories' )
754                        ->numParams( count( $hiddenCategories ) ),
755                    Linker::formatHiddenCategories( $hiddenCategories )
756                ];
757            }
758
759            // Transcluded templates
760            if ( $pageCounts['transclusion']['from'] > 0 ) {
761                if ( $pageCounts['transclusion']['from'] > count( $transcludedTemplates ) ) {
762                    $more = $this->msg( 'morenotlisted' )->escaped();
763                } else {
764                    $more = null;
765                }
766
767                $templateListFormatter = new TemplatesOnThisPageFormatter(
768                    $this->getContext(),
769                    $linkRenderer,
770                    $this->linkBatchFactory,
771                    $this->restrictionStore
772                );
773
774                $pageInfo['header-properties'][] = [
775                    $this->msg( 'pageinfo-templates' )
776                        ->numParams( $pageCounts['transclusion']['from'] ),
777                    $templateListFormatter->format( $transcludedTemplates, false, $more )
778                ];
779            }
780
781            if ( !$config->get( MainConfigNames::MiserMode ) && $pageCounts['transclusion']['to'] > 0 ) {
782                if ( $pageCounts['transclusion']['to'] > count( $transcludedTargets ) ) {
783                    $more = $linkRenderer->makeLink(
784                        $whatLinksHere,
785                        $this->msg( 'moredotdotdot' )->text(),
786                        [],
787                        [ 'hidelinks' => 1, 'hideredirs' => 1 ]
788                    );
789                } else {
790                    $more = null;
791                }
792
793                $templateListFormatter = new TemplatesOnThisPageFormatter(
794                    $this->getContext(),
795                    $linkRenderer,
796                    $this->linkBatchFactory,
797                    $this->restrictionStore
798                );
799
800                $pageInfo['header-properties'][] = [
801                    $this->msg( 'pageinfo-transclusions' )
802                        ->numParams( $pageCounts['transclusion']['to'] ),
803                    $templateListFormatter->format( $transcludedTargets, false, $more )
804                ];
805            }
806        }
807
808        return $pageInfo;
809    }
810
811    /**
812     * Get namespace protection message for title or null if no namespace protection
813     * has been applied
814     *
815     * @param Title $title
816     * @return ?string HTML
817     */
818    private function getNamespaceProtectionMessage( Title $title ): ?string {
819        $rights = [];
820        if ( $title->isRawHtmlMessage() ) {
821            $rights[] = 'editsitecss';
822            $rights[] = 'editsitejs';
823        } elseif ( $title->isSiteCssConfigPage() ) {
824            $rights[] = 'editsitecss';
825        } elseif ( $title->isSiteJsConfigPage() ) {
826            $rights[] = 'editsitejs';
827        } elseif ( $title->isSiteJsonConfigPage() ) {
828            $rights[] = 'editsitejson';
829        } elseif ( $title->isUserCssConfigPage() ) {
830            $rights[] = 'editusercss';
831        } elseif ( $title->isUserJsConfigPage() ) {
832            $rights[] = 'edituserjs';
833        } elseif ( $title->isUserJsonConfigPage() ) {
834            $rights[] = 'edituserjson';
835        } else {
836            $namespaceProtection = $this->context->getConfig()->get( MainConfigNames::NamespaceProtection );
837            $right = $namespaceProtection[$title->getNamespace()] ?? null;
838            if ( $right ) {
839                // a single string as the value is allowed as well as an array
840                $rights = (array)$right;
841            }
842        }
843        if ( $rights ) {
844            return $this->msg( 'protect-fallback', $this->getLanguage()->commaList( $rights ) )->parse();
845        } else {
846            return null;
847        }
848    }
849
850    /**
851     * Returns page counts that would be too "expensive" to retrieve by normal means.
852     *
853     * @return array
854     */
855    private function pageCounts() {
856        $page = $this->getWikiPage();
857        $fname = __METHOD__;
858        $config = $this->context->getConfig();
859        $cache = $this->wanObjectCache;
860
861        return $cache->getWithSetCallback(
862            self::getCacheKey( $cache, $page->getTitle(), $page->getLatest() ),
863            WANObjectCache::TTL_WEEK,
864            function ( $oldValue, &$ttl, &$setOpts ) use ( $page, $config, $fname ) {
865                $title = $page->getTitle();
866                $id = $title->getArticleID();
867
868                $dbr = $this->dbProvider->getReplicaDatabase();
869                $setOpts += Database::getCacheSetOptions( $dbr );
870
871                $field = 'rev_actor';
872                $pageField = 'rev_page';
873
874                $watchedItemStore = $this->watchedItemStore;
875
876                $result = [];
877                $result['watchers'] = $watchedItemStore->countWatchers( $title );
878
879                if ( $config->get( MainConfigNames::ShowUpdatedMarker ) ) {
880                    $updated = (int)wfTimestamp( TS_UNIX, $page->getTimestamp() );
881                    $result['visitingWatchers'] = $watchedItemStore->countVisitingWatchers(
882                        $title,
883                        $updated - $config->get( MainConfigNames::WatchersMaxAge )
884                    );
885                }
886
887                // Total number of edits
888                $edits = (int)$dbr->newSelectQueryBuilder()
889                    ->select( 'COUNT(*)' )
890                    ->from( 'revision' )
891                    ->where( [ 'rev_page' => $id ] )
892                    ->caller( $fname )
893                    ->fetchField();
894                $result['edits'] = $edits;
895
896                // Total number of distinct authors
897                if ( $config->get( MainConfigNames::MiserMode ) ) {
898                    $result['authors'] = 0;
899                } else {
900                    $result['authors'] = (int)$dbr->newSelectQueryBuilder()
901                        ->select( "COUNT(DISTINCT $field)" )
902                        ->from( 'revision' )
903                        ->where( [ $pageField => $id ] )
904                        ->caller( $fname )
905                        ->fetchField();
906                }
907
908                // "Recent" threshold defined by RCMaxAge setting
909                $threshold = $dbr->timestamp( time() - $config->get( MainConfigNames::RCMaxAge ) );
910
911                // Recent number of edits
912                $edits = (int)$dbr->newSelectQueryBuilder()
913                    ->select( 'COUNT(rev_page)' )
914                    ->from( 'revision' )
915                    ->where( [ 'rev_page' => $id ] )
916                    ->andWhere( $dbr->expr( 'rev_timestamp', '>=', $threshold ) )
917                    ->caller( $fname )
918                    ->fetchField();
919                $result['recent_edits'] = $edits;
920
921                // Recent number of distinct authors
922                $result['recent_authors'] = (int)$dbr->newSelectQueryBuilder()
923                    ->select( "COUNT(DISTINCT $field)" )
924                    ->from( 'revision' )
925                    ->where( [ $pageField => $id ] )
926                    ->andWhere( [ $dbr->expr( 'rev_timestamp', '>=', $threshold ) ] )
927                    ->caller( $fname )
928                    ->fetchField();
929
930                // Subpages (if enabled)
931                if ( $this->namespaceInfo->hasSubpages( $title->getNamespace() ) ) {
932                    $conds = [ 'page_namespace' => $title->getNamespace() ];
933                    $conds[] = $dbr->expr(
934                        'page_title',
935                        IExpression::LIKE,
936                        new LikeValue( $title->getDBkey() . '/', $dbr->anyString() )
937                    );
938
939                    // Subpages of this page (redirects)
940                    $conds['page_is_redirect'] = 1;
941                    $result['subpages']['redirects'] = (int)$dbr->newSelectQueryBuilder()
942                        ->select( 'COUNT(page_id)' )
943                        ->from( 'page' )
944                        ->where( $conds )
945                        ->caller( $fname )
946                        ->fetchField();
947                    // Subpages of this page (non-redirects)
948                    $conds['page_is_redirect'] = 0;
949                    $result['subpages']['nonredirects'] = (int)$dbr->newSelectQueryBuilder()
950                        ->select( 'COUNT(page_id)' )
951                        ->from( 'page' )
952                        ->where( $conds )
953                        ->caller( $fname )
954                        ->fetchField();
955
956                    // Subpages of this page (total)
957                    $result['subpages']['total'] = $result['subpages']['redirects']
958                        + $result['subpages']['nonredirects'];
959                }
960
961                $dbrTemplateLinks = $this->dbProvider->getReplicaDatabase( TemplateLinksTable::VIRTUAL_DOMAIN );
962                // Counts for the number of transclusion links (to/from)
963                if ( $config->get( MainConfigNames::MiserMode ) ) {
964                    $result['transclusion']['to'] = 0;
965                } else {
966                    $result['transclusion']['to'] = (int)$dbrTemplateLinks->newSelectQueryBuilder()
967                        ->select( 'COUNT(tl_from)' )
968                        ->from( 'templatelinks' )
969                        ->where( $this->linksMigration->getLinksConditions( 'templatelinks', $title ) )
970                        ->caller( $fname )
971                        ->fetchField();
972                }
973
974                $result['transclusion']['from'] = (int)$dbrTemplateLinks->newSelectQueryBuilder()
975                    ->select( 'COUNT(*)' )
976                    ->from( 'templatelinks' )
977                    ->where( [ 'tl_from' => $title->getArticleID() ] )
978                    ->caller( $fname )
979                    ->fetchField();
980
981                return $result;
982            }
983        );
984    }
985
986    /**
987     * Returns the name that goes in the "<h1>" page title.
988     *
989     * @return Message
990     */
991    protected function getPageTitle() {
992        return $this->msg( 'pageinfo-title' )->plaintextParams( $this->getTitle()->getPrefixedText() );
993    }
994
995    /**
996     * Returns the description that goes below the "<h1>" tag.
997     *
998     * @return string
999     */
1000    protected function getDescription() {
1001        return '';
1002    }
1003
1004    /**
1005     * @param WANObjectCache $cache
1006     * @param PageIdentity $page
1007     * @param int $revId
1008     * @return string
1009     */
1010    protected static function getCacheKey( WANObjectCache $cache, PageIdentity $page, $revId ) {
1011        return $cache->makeKey( 'infoaction', md5( (string)$page ), $revId, self::VERSION );
1012    }
1013}
1014
1015/** @deprecated class alias since 1.44 */
1016class_alias( InfoAction::class, 'InfoAction' );