Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 776
0.00% covered (danger)
0.00%
0 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialVersion
0.00% covered (danger)
0.00%
0 / 775
0.00% covered (danger)
0.00%
0 / 36
23256
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getCredits
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 execute
0.00% covered (danger)
0.00%
0 / 96
0.00% covered (danger)
0.00%
0 / 1
506
 addTocSection
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 addTocSubSection
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 getMediaWikiCredits
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 getCopyrightAndAuthorList
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 getSoftwareInformation
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 softwareInformation
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 getVersion
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getVersionLinked
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getMWVersionLinked
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getVersionLinkedGit
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getExtensionTypes
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 getExtensionTypeName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getExtensionCredits
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
90
 getSkinCredits
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 getLibraries
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getExternalLibraries
0.00% covered (danger)
0.00%
0 / 70
0.00% covered (danger)
0.00%
0 / 1
182
 parseForeignResources
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getClientSideLibraries
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
6
 getParserTags
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
6
 getParserFunctionHooks
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
6
 getExtensionCategory
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 compare
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCreditsForExtension
0.00% covered (danger)
0.00%
0 / 113
0.00% covered (danger)
0.00%
0 / 1
462
 getHooks
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
20
 openExtType
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 getTableHeaderHtml
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 IPInfo
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 listAuthors
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
210
 listToText
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 arrayToString
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 getGitHeadSha1
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getEntryPointInfo
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
12
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright © 2005 Ævar Arnfjörð Bjarmason
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23namespace MediaWiki\Specials;
24
25use Closure;
26use ExtensionRegistry;
27use HtmlArmor;
28use Language;
29use MediaWiki\Config\Config;
30use MediaWiki\HookContainer\HookRunner;
31use MediaWiki\Html\Html;
32use MediaWiki\Language\RawMessage;
33use MediaWiki\MainConfigNames;
34use MediaWiki\MediaWikiServices;
35use MediaWiki\Message\Message;
36use MediaWiki\Parser\Parser;
37use MediaWiki\Parser\ParserOutput;
38use MediaWiki\Parser\ParserOutputFlags;
39use MediaWiki\Parser\Sanitizer;
40use MediaWiki\SpecialPage\SpecialPage;
41use MediaWiki\Utils\ExtensionInfo;
42use MediaWiki\Utils\GitInfo;
43use MediaWiki\Utils\MWTimestamp;
44use MediaWiki\Utils\UrlUtils;
45use ParserFactory;
46use Symfony\Component\Yaml\Yaml;
47use Wikimedia\Composer\ComposerInstalled;
48use Wikimedia\Parsoid\Core\SectionMetadata;
49use Wikimedia\Parsoid\Core\TOCData;
50use Wikimedia\Rdbms\IConnectionProvider;
51
52/**
53 * Version information about MediaWiki (core, extensions, libs), PHP, and the database.
54 *
55 * @ingroup SpecialPage
56 */
57class SpecialVersion extends SpecialPage {
58
59    /**
60     * @var string The current rev id/SHA hash of MediaWiki core
61     */
62    protected $coreId = '';
63
64    /**
65     * @var string[]|false Lazy initialized key/value with message content
66     */
67    protected static $extensionTypes = false;
68
69    /** @var TOCData */
70    protected $tocData;
71
72    /** @var int */
73    protected $tocIndex;
74
75    /** @var int */
76    protected $tocSection;
77
78    /** @var int */
79    protected $tocSubSection;
80
81    private ParserFactory $parserFactory;
82    private UrlUtils $urlUtils;
83    private IConnectionProvider $dbProvider;
84
85    /**
86     * @param ParserFactory $parserFactory
87     * @param UrlUtils $urlUtils
88     * @param IConnectionProvider $dbProvider
89     */
90    public function __construct(
91        ParserFactory $parserFactory,
92        UrlUtils $urlUtils,
93        IConnectionProvider $dbProvider
94    ) {
95        parent::__construct( 'Version' );
96        $this->parserFactory = $parserFactory;
97        $this->urlUtils = $urlUtils;
98        $this->dbProvider = $dbProvider;
99    }
100
101    /**
102     * @since 1.35
103     * @param ExtensionRegistry $reg
104     * @param Config $conf For additional entries from $wgExtensionCredits.
105     * @return array[]
106     * @see $wgExtensionCredits
107     */
108    public static function getCredits( ExtensionRegistry $reg, Config $conf ): array {
109        $credits = $conf->get( MainConfigNames::ExtensionCredits );
110        foreach ( $reg->getAllThings() as $credit ) {
111            $credits[$credit['type']][] = $credit;
112        }
113        return $credits;
114    }
115
116    /**
117     * @param string|null $par
118     */
119    public function execute( $par ) {
120        $config = $this->getConfig();
121        $credits = self::getCredits( ExtensionRegistry::getInstance(), $config );
122
123        $this->setHeaders();
124        $this->outputHeader();
125        $out = $this->getOutput();
126        $out->setPreventClickjacking( false );
127
128        // Explode the sub page information into useful bits
129        $parts = explode( '/', (string)$par );
130        $extNode = null;
131        if ( isset( $parts[1] ) ) {
132            $extName = str_replace( '_', ' ', $parts[1] );
133            // Find it!
134            foreach ( $credits as $extensions ) {
135                foreach ( $extensions as $ext ) {
136                    if ( isset( $ext['name'] ) && ( $ext['name'] === $extName ) ) {
137                        $extNode = &$ext;
138                        break 2;
139                    }
140                }
141            }
142            if ( !$extNode ) {
143                $out->setStatusCode( 404 );
144            }
145        } else {
146            $extName = 'MediaWiki';
147        }
148
149        // Now figure out what to do
150        switch ( strtolower( $parts[0] ) ) {
151            case 'credits':
152                $out->addModuleStyles( 'mediawiki.special' );
153
154                $wikiText = '{{int:version-credits-not-found}}';
155                if ( $extName === 'MediaWiki' ) {
156                    $wikiText = file_get_contents( MW_INSTALL_PATH . '/CREDITS' );
157                    // Put the contributor list into columns
158                    $wikiText = str_replace(
159                        [ '<!-- BEGIN CONTRIBUTOR LIST -->', '<!-- END CONTRIBUTOR LIST -->' ],
160                        [ '<div class="mw-version-credits">', '</div>' ],
161                        $wikiText
162                    );
163                } elseif ( ( $extNode !== null ) && isset( $extNode['path'] ) ) {
164                    $file = ExtensionInfo::getAuthorsFileName( dirname( $extNode['path'] ) );
165                    if ( $file ) {
166                        $wikiText = file_get_contents( $file );
167                        if ( str_ends_with( $file, '.txt' ) ) {
168                            $wikiText = Html::element(
169                                'pre',
170                                [
171                                    'lang' => 'en',
172                                    'dir' => 'ltr',
173                                ],
174                                $wikiText
175                            );
176                        }
177                    }
178                }
179
180                $out->setPageTitleMsg( $this->msg( 'version-credits-title' )->plaintextParams( $extName ) );
181                $out->addWikiTextAsInterface( $wikiText );
182                break;
183
184            case 'license':
185                $out->setPageTitleMsg( $this->msg( 'version-license-title' )->plaintextParams( $extName ) );
186
187                $licenseFound = false;
188
189                if ( $extName === 'MediaWiki' ) {
190                    $out->addWikiTextAsInterface(
191                        file_get_contents( MW_INSTALL_PATH . '/COPYING' )
192                    );
193                    $licenseFound = true;
194                } elseif ( ( $extNode !== null ) && isset( $extNode['path'] ) ) {
195                    $files = ExtensionInfo::getLicenseFileNames( dirname( $extNode['path'] ) );
196                    if ( $files ) {
197                        $licenseFound = true;
198                        foreach ( $files as $file ) {
199                            $out->addWikiTextAsInterface(
200                                Html::element(
201                                    'pre',
202                                    [
203                                        'lang' => 'en',
204                                        'dir' => 'ltr',
205                                    ],
206                                    file_get_contents( $file )
207                                )
208                            );
209                        }
210                    }
211                }
212                if ( !$licenseFound ) {
213                    $out->addWikiTextAsInterface( '{{int:version-license-not-found}}' );
214                }
215                break;
216
217            default:
218                $out->addModuleStyles( 'mediawiki.special' );
219
220                $out->addHTML( $this->getMediaWikiCredits() );
221
222                $this->tocData = new TOCData();
223                $this->tocIndex = 0;
224                $this->tocSection = 0;
225                $this->tocSubSection = 0;
226
227                // Build the page contents (this also fills in TOCData)
228                $sections = [
229                    $this->softwareInformation(),
230                    $this->getEntryPointInfo(),
231                    $this->getSkinCredits( $credits ),
232                    $this->getExtensionCredits( $credits ),
233                    $this->getLibraries( $credits ),
234                    $this->getParserTags(),
235                    $this->getParserFunctionHooks(),
236                    $this->getHooks(),
237                    $this->IPInfo(),
238                ];
239
240                // Insert TOC first
241                $pout = new ParserOutput;
242                $pout->setTOCData( $this->tocData );
243                $pout->setOutputFlag( ParserOutputFlags::SHOW_TOC );
244                $pout->setRawText( Parser::TOC_PLACEHOLDER );
245                $out->addParserOutput( $pout );
246
247                // Insert contents
248                foreach ( $sections as $content ) {
249                    $out->addHTML( $content );
250                }
251
252                break;
253        }
254    }
255
256    /**
257     * Add a section to the table of contents. This doesn't add the heading to the actual page.
258     * Assumes the IDs don't use non-ASCII characters.
259     *
260     * @param string $labelMsg Message key to use for the label
261     * @param string $id
262     */
263    private function addTocSection( $labelMsg, $id ) {
264        $this->tocIndex++;
265        $this->tocSection++;
266        $this->tocSubSection = 0;
267        $this->tocData->addSection( new SectionMetadata(
268            1,
269            2,
270            $this->msg( $labelMsg )->escaped(),
271            $this->getLanguage()->formatNum( $this->tocSection ),
272            (string)$this->tocIndex,
273            null,
274            null,
275            $id,
276            $id
277        ) );
278    }
279
280    /**
281     * Add a sub-section to the table of contents. This doesn't add the heading to the actual page.
282     * Assumes the IDs don't use non-ASCII characters.
283     *
284     * @param string $label Text of the label
285     * @param string $id
286     */
287    private function addTocSubSection( $label, $id ) {
288        $this->tocIndex++;
289        $this->tocSubSection++;
290        $this->tocData->addSection( new SectionMetadata(
291            2,
292            3,
293            htmlspecialchars( $label ),
294            // See Parser::localizeTOC
295            $this->getLanguage()->formatNum( $this->tocSection ) . '.' .
296                $this->getLanguage()->formatNum( $this->tocSubSection ),
297            (string)$this->tocIndex,
298            null,
299            null,
300            $id,
301            $id
302        ) );
303    }
304
305    /**
306     * Returns HTML showing the license information.
307     *
308     * @return string HTML
309     */
310    private function getMediaWikiCredits() {
311        // No TOC entry for this heading, we treat it like the lede section
312
313        $ret = Html::element(
314            'h2',
315            [ 'id' => 'mw-version-license' ],
316            $this->msg( 'version-license' )->text()
317        );
318
319        $ret .= Html::rawElement( 'div', [ 'class' => 'plainlinks' ],
320            $this->msg( new RawMessage( self::getCopyrightAndAuthorList() ) )->parseAsBlock() .
321            Html::rawElement( 'div', [ 'class' => 'mw-version-license-info' ],
322                $this->msg( 'version-license-info' )->parseAsBlock()
323            )
324        );
325
326        return $ret;
327    }
328
329    /**
330     * Get the "MediaWiki is copyright 2001-20xx by lots of cool folks" text
331     *
332     * @internal For use by WebInstallerWelcome
333     * @return string Wikitext
334     */
335    public static function getCopyrightAndAuthorList() {
336        if ( defined( 'MEDIAWIKI_INSTALL' ) ) {
337            $othersLink = '[https://www.mediawiki.org/wiki/Special:Version/Credits ' .
338                wfMessage( 'version-poweredby-others' )->plain() . ']';
339        } else {
340            $othersLink = '[[Special:Version/Credits|' .
341                wfMessage( 'version-poweredby-others' )->plain() . ']]';
342        }
343
344        $translatorsLink = '[https://translatewiki.net/wiki/Translating:MediaWiki/Credits ' .
345            wfMessage( 'version-poweredby-translators' )->plain() . ']';
346
347        $authorList = [
348            'Magnus Manske', 'Brooke Vibber', 'Lee Daniel Crocker',
349            'Tim Starling', 'Erik Möller', 'Gabriel Wicke', 'Ævar Arnfjörð Bjarmason',
350            'Niklas Laxström', 'Domas Mituzas', 'Rob Church', 'Yuri Astrakhan',
351            'Aryeh Gregor', 'Aaron Schulz', 'Andrew Garrett', 'Raimond Spekking',
352            'Alexandre Emsenhuber', 'Siebrand Mazeland', 'Chad Horohoe',
353            'Roan Kattouw', 'Trevor Parscal', 'Bryan Tong Minh', 'Sam Reed',
354            'Victor Vasiliev', 'Rotem Liss', 'Platonides', 'Antoine Musso',
355            'Timo Tijhof', 'Daniel Kinzler', 'Jeroen De Dauw', 'Brad Jorsch',
356            'Bartosz Dziewoński', 'Ed Sanders', 'Moriel Schottlender',
357            'Kunal Mehta', 'James D. Forrester', 'Brian Wolff', 'Adam Shorland',
358            'DannyS712', 'Ori Livneh', 'Max Semenik', 'Amir Sarabadani',
359            'Derk-Jan Hartman', 'Petr Pchelko',
360            $othersLink, $translatorsLink
361        ];
362
363        return wfMessage( 'version-poweredby-credits', MWTimestamp::getLocalInstance()->format( 'Y' ),
364            Message::listParam( $authorList ) )->plain();
365    }
366
367    /**
368     * Helper for self::softwareInformation().
369     * @since 1.34
370     * @return string[] Array of wikitext strings keyed by wikitext strings
371     */
372    private function getSoftwareInformation() {
373        $dbr = $this->dbProvider->getReplicaDatabase();
374
375        // Put the software in an array of form 'name' => 'version'. All messages should
376        // be loaded here, so feel free to use wfMessage in the 'name'. Wikitext
377        // can be used both in the name and value.
378        $software = [
379            '[https://www.mediawiki.org/ MediaWiki]' => self::getVersionLinked(),
380            '[https://php.net/ PHP]' => PHP_VERSION . " (" . PHP_SAPI . ")",
381            '[https://icu.unicode.org/ ICU]' => INTL_ICU_VERSION,
382            $dbr->getSoftwareLink() => $dbr->getServerInfo(),
383        ];
384
385        // T339915: If wikidiff2 is installed, show version
386        if ( phpversion( "wikidiff2" ) ) {
387            $software[ '[https://www.mediawiki.org/wiki/Wikidiff2 wikidiff2]' ] = phpversion( "wikidiff2" );
388        }
389
390        // Allow a hook to add/remove items.
391        $this->getHookRunner()->onSoftwareInfo( $software );
392
393        return $software;
394    }
395
396    /**
397     * Returns HTML showing the third party software versions (apache, php, mysql).
398     *
399     * @return string HTML
400     */
401    private function softwareInformation() {
402        $this->addTocSection( 'version-software', 'mw-version-software' );
403
404        $out = Html::element(
405            'h2',
406            [ 'id' => 'mw-version-software' ],
407            $this->msg( 'version-software' )->text()
408        );
409
410        $out .= Html::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-software' ] );
411
412        $out .= $this->getTableHeaderHtml( [
413            $this->msg( 'version-software-product' )->text(),
414            $this->msg( 'version-software-version' )->text()
415        ] );
416
417        foreach ( $this->getSoftwareInformation() as $name => $version ) {
418            $out .= Html::rawElement(
419                'tr',
420                [],
421                Html::rawElement( 'td', [], $this->msg( new RawMessage( $name ) )->parse() ) .
422                    Html::rawElement( 'td', [ 'dir' => 'ltr' ], $this->msg( new RawMessage( $version ) )->parse() )
423            );
424        }
425
426        $out .= Html::closeElement( 'table' );
427
428        return $out;
429    }
430
431    /**
432     * Return a string of the MediaWiki version with Git revision if available.
433     *
434     * @param string $flags If set to 'nodb', the language-specific parantheses are not used.
435     * @param Language|string|null $lang Language in which to render the version; ignored if
436     *   $flags is set to 'nodb'.
437     * @return string A version string, as wikitext. This should be parsed
438     *   (unless `nodb` is set) and escaped before being inserted as HTML.
439     */
440    public static function getVersion( $flags = '', $lang = null ) {
441        $gitInfo = GitInfo::repo()->getHeadSHA1();
442        if ( !$gitInfo ) {
443            $version = MW_VERSION;
444        } elseif ( $flags === 'nodb' ) {
445            $shortSha1 = substr( $gitInfo, 0, 7 );
446            $version = MW_VERSION . " ($shortSha1)";
447        } else {
448            $shortSha1 = substr( $gitInfo, 0, 7 );
449            $msg = wfMessage( 'parentheses' );
450            if ( $lang !== null ) {
451                $msg->inLanguage( $lang );
452            }
453            $shortSha1 = $msg->params( $shortSha1 )->text();
454            $version = MW_VERSION . ' ' . $shortSha1;
455        }
456
457        return $version;
458    }
459
460    /**
461     * Return a wikitext-formatted string of the MediaWiki version with a link to
462     * the Git SHA1 of head if available.
463     * The fallback is just MW_VERSION.
464     *
465     * @return string
466     */
467    public static function getVersionLinked() {
468        return self::getVersionLinkedGit() ?: MW_VERSION;
469    }
470
471    /**
472     * @return string
473     */
474    private static function getMWVersionLinked() {
475        $versionUrl = "";
476        $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
477        if ( $hookRunner->onSpecialVersionVersionUrl( MW_VERSION, $versionUrl ) ) {
478            $versionParts = [];
479            preg_match( "/^(\d+\.\d+)/", MW_VERSION, $versionParts );
480            $versionUrl = "https://www.mediawiki.org/wiki/MediaWiki_{$versionParts[1]}";
481        }
482
483        return '[' . $versionUrl . ' ' . MW_VERSION . ']';
484    }
485
486    /**
487     * @since 1.22 Includes the date of the Git HEAD commit
488     * @return bool|string MW version and Git HEAD (SHA1 stripped to the first 7 chars)
489     *   with link and date, or false on failure
490     */
491    private static function getVersionLinkedGit() {
492        global $wgLang;
493
494        $gitInfo = new GitInfo( MW_INSTALL_PATH );
495        $headSHA1 = $gitInfo->getHeadSHA1();
496        if ( !$headSHA1 ) {
497            return false;
498        }
499
500        $shortSHA1 = '(' . substr( $headSHA1, 0, 7 ) . ')';
501
502        $gitHeadUrl = $gitInfo->getHeadViewUrl();
503        if ( $gitHeadUrl !== false ) {
504            $shortSHA1 = "[$gitHeadUrl $shortSHA1]";
505        }
506
507        $gitHeadCommitDate = $gitInfo->getHeadCommitDate();
508        if ( $gitHeadCommitDate ) {
509            $shortSHA1 .= Html::element( 'br' ) . $wgLang->timeanddate( (string)$gitHeadCommitDate, true );
510        }
511
512        return self::getMWVersionLinked() . " $shortSHA1";
513    }
514
515    /**
516     * Returns an array with the base extension types.
517     * Type is stored as array key, the message as array value.
518     *
519     * TODO: ideally this would return all extension types.
520     *
521     * @since 1.17
522     * @return string[]
523     */
524    public static function getExtensionTypes(): array {
525        if ( self::$extensionTypes === false ) {
526            self::$extensionTypes = [
527                'specialpage' => wfMessage( 'version-specialpages' )->text(),
528                'editor' => wfMessage( 'version-editors' )->text(),
529                'parserhook' => wfMessage( 'version-parserhooks' )->text(),
530                'variable' => wfMessage( 'version-variables' )->text(),
531                'media' => wfMessage( 'version-mediahandlers' )->text(),
532                'antispam' => wfMessage( 'version-antispam' )->text(),
533                'skin' => wfMessage( 'version-skins' )->text(),
534                'api' => wfMessage( 'version-api' )->text(),
535                'other' => wfMessage( 'version-other' )->text(),
536            ];
537
538            ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
539                ->onExtensionTypes( self::$extensionTypes );
540        }
541
542        return self::$extensionTypes;
543    }
544
545    /**
546     * Returns the internationalized name for an extension type.
547     *
548     * @since 1.17
549     *
550     * @param string $type
551     *
552     * @return string
553     */
554    public static function getExtensionTypeName( $type ) {
555        $types = self::getExtensionTypes();
556
557        return $types[$type] ?? $types['other'];
558    }
559
560    /**
561     * Generate HTML showing the name, URL, author and description of each extension.
562     *
563     * @param array $credits
564     * @return string HTML
565     */
566    private function getExtensionCredits( array $credits ) {
567        $extensionTypes = self::getExtensionTypes();
568
569        $this->addTocSection( 'version-extensions', 'mw-version-ext' );
570
571        $out = Html::element(
572            'h2',
573            [ 'id' => 'mw-version-ext' ],
574            $this->msg( 'version-extensions' )->text()
575        );
576
577        if (
578            !$credits ||
579            // Skins are displayed separately, see getSkinCredits()
580            ( count( $credits ) === 1 && isset( $credits['skin'] ) )
581        ) {
582            $out .= Html::element(
583                'p',
584                [],
585                $this->msg( 'version-extensions-no-ext' )->text()
586            );
587
588            return $out;
589        }
590
591        // Find all extensions that do not have a valid type and give them the type 'other'.
592        $credits['other'] ??= [];
593        foreach ( $credits as $type => $extensions ) {
594            if ( !array_key_exists( $type, $extensionTypes ) ) {
595                $credits['other'] = array_merge( $credits['other'], $extensions );
596            }
597        }
598
599        // Loop through the extension categories to display their extensions in the list.
600        foreach ( $extensionTypes as $type => $text ) {
601            // Skins have a separate section
602            if ( $type !== 'other' && $type !== 'skin' ) {
603                $out .= $this->getExtensionCategory( $type, $text, $credits[$type] ?? [] );
604            }
605        }
606
607        // We want the 'other' type to be last in the list.
608        $out .= $this->getExtensionCategory( 'other', $extensionTypes['other'], $credits['other'] );
609
610        return $out;
611    }
612
613    /**
614     * Generate HTML showing the name, URL, author and description of each skin.
615     *
616     * @param array $credits
617     * @return string HTML
618     */
619    private function getSkinCredits( array $credits ) {
620        $this->addTocSection( 'version-skins', 'mw-version-skin' );
621
622        $out = Html::element(
623            'h2',
624            [ 'id' => 'mw-version-skin' ],
625            $this->msg( 'version-skins' )->text()
626        );
627
628        if ( !isset( $credits['skin'] ) || !$credits['skin'] ) {
629            $out .= Html::element(
630                'p',
631                [],
632                $this->msg( 'version-skins-no-skin' )->text()
633            );
634
635            return $out;
636        }
637        $out .= $this->getExtensionCategory( 'skin', null, $credits['skin'] );
638
639        return $out;
640    }
641
642    /**
643     * Generate the section for installed external libraries
644     *
645     * @param array $credits
646     * @return string
647     */
648    protected function getLibraries( array $credits ) {
649        $this->addTocSection( 'version-libraries', 'mw-version-libraries' );
650
651        $out = Html::element(
652            'h2',
653            [ 'id' => 'mw-version-libraries' ],
654            $this->msg( 'version-libraries' )->text()
655        );
656
657        return $out
658            . $this->getExternalLibraries( $credits )
659            . $this->getClientSideLibraries();
660    }
661
662    /**
663     * Generate an HTML table for external server-side libraries that are installed
664     *
665     * @param array $credits
666     * @return string
667     */
668    protected function getExternalLibraries( array $credits ) {
669        $paths = [
670            MW_INSTALL_PATH . '/vendor/composer/installed.json'
671        ];
672
673        $extensionTypes = self::getExtensionTypes();
674        foreach ( $extensionTypes as $type => $message ) {
675            if ( !isset( $credits[$type] ) || $credits[$type] === [] ) {
676                continue;
677            }
678            foreach ( $credits[$type] as $extension ) {
679                if ( !isset( $extension['path'] ) ) {
680                    continue;
681                }
682                $paths[] = dirname( $extension['path'] ) . '/vendor/composer/installed.json';
683            }
684        }
685
686        $dependencies = [];
687
688        foreach ( $paths as $path ) {
689            if ( !file_exists( $path ) ) {
690                continue;
691            }
692
693            $installed = new ComposerInstalled( $path );
694
695            $dependencies += $installed->getInstalledDependencies();
696        }
697
698        if ( $dependencies === [] ) {
699            return '';
700        }
701
702        ksort( $dependencies );
703
704        $this->addTocSubSection( $this->msg( 'version-libraries-server' )->text(), 'mw-version-libraries-server' );
705
706        $out = Html::element(
707            'h3',
708            [ 'id' => 'mw-version-libraries-server' ],
709            $this->msg( 'version-libraries-server' )->text()
710        );
711        $out .= Html::openElement(
712            'table',
713            [ 'class' => 'wikitable plainlinks mw-installed-software', 'id' => 'sv-libraries' ]
714        );
715
716        $out .= $this->getTableHeaderHtml( [
717            $this->msg( 'version-libraries-library' )->text(),
718            $this->msg( 'version-libraries-version' )->text(),
719            $this->msg( 'version-libraries-license' )->text(),
720            $this->msg( 'version-libraries-description' )->text(),
721            $this->msg( 'version-libraries-authors' )->text(),
722        ] );
723
724        foreach ( $dependencies as $name => $info ) {
725            if ( !is_array( $info ) || str_starts_with( $info['type'], 'mediawiki-' ) ) {
726                // Skip any extensions or skins since they'll be listed
727                // in their proper section
728                continue;
729            }
730            $authors = array_map( static function ( $arr ) {
731                return new HtmlArmor( isset( $arr['homepage'] ) ?
732                    Html::element( 'a', [ 'href' => $arr['homepage'] ], $arr['name'] ) :
733                    htmlspecialchars( $arr['name'] )
734                );
735            }, $info['authors'] );
736            $authors = $this->listAuthors( $authors, false, MW_INSTALL_PATH . "/vendor/$name" );
737
738            // We can safely assume that the libraries' names and descriptions
739            // are written in English and aren't going to be translated,
740            // so set appropriate lang and dir attributes
741            $out .= Html::openElement( 'tr', [
742                // Add an anchor so docs can link easily to the version of
743                // this specific library
744                'id' => Sanitizer::escapeIdForAttribute(
745                    "mw-version-library-$name"
746                ) ] )
747                . Html::rawElement(
748                    'td',
749                    [],
750                    $this->getLinkRenderer()->makeExternalLink(
751                        "https://packagist.org/packages/$name",
752                        $name,
753                        $this->getFullTitle(),
754                        '',
755                        [ 'class' => 'mw-version-library-name' ]
756                    )
757                )
758                . Html::element( 'td', [ 'dir' => 'auto' ], $info['version'] )
759                // @phan-suppress-next-line SecurityCheck-DoubleEscaped See FIXME in listToText
760                . Html::element( 'td', [ 'dir' => 'auto' ], $this->listToText( $info['licenses'] ) )
761                . Html::element( 'td', [ 'lang' => 'en', 'dir' => 'ltr' ], $info['description'] )
762                . Html::rawElement( 'td', [], $authors )
763                . Html::closeElement( 'tr' );
764        }
765        $out .= Html::closeElement( 'table' );
766
767        return $out;
768    }
769
770    /**
771     * @internal
772     * @since 1.42
773     * @return array
774     */
775    public static function parseForeignResources() {
776        $registryDirs = [ 'MediaWiki' => MW_INSTALL_PATH . '/resources/lib' ]
777            + ExtensionRegistry::getInstance()->getAttribute( 'ForeignResourcesDir' );
778
779        $modules = [];
780        foreach ( $registryDirs as $source => $registryDir ) {
781            $foreignResources = Yaml::parseFile( "$registryDir/foreign-resources.yaml" );
782            foreach ( $foreignResources as $name => $module ) {
783                $key = $name . $module['version'];
784                if ( isset( $modules[$key] ) ) {
785                    $modules[$key]['source'][] = $source;
786                    continue;
787                }
788                $modules[$key] = $module + [ 'name' => $name, 'source' => [ $source ] ];
789            }
790        }
791        ksort( $modules );
792        return $modules;
793    }
794
795    /**
796     * Generate an HTML table for client-side libraries that are installed
797     *
798     * @return string HTML output
799     */
800    private function getClientSideLibraries() {
801        $this->addTocSubSection( $this->msg( 'version-libraries-client' )->text(), 'mw-version-libraries-client' );
802
803        $out = Html::element(
804            'h3',
805            [ 'id' => 'mw-version-libraries-client' ],
806            $this->msg( 'version-libraries-client' )->text()
807        );
808        $out .= Html::openElement(
809            'table',
810            [ 'class' => 'wikitable plainlinks mw-installed-software', 'id' => 'sv-libraries-client' ]
811        );
812
813        $out .= $this->getTableHeaderHtml( [
814            $this->msg( 'version-libraries-library' )->text(),
815            $this->msg( 'version-libraries-version' )->text(),
816            $this->msg( 'version-libraries-license' )->text(),
817            $this->msg( 'version-libraries-authors' )->text(),
818            $this->msg( 'version-libraries-source' )->text()
819        ] );
820
821        foreach ( self::parseForeignResources() as $name => $info ) {
822            // We can safely assume that the libraries' names and descriptions
823            // are written in English and aren't going to be translated,
824            // so set appropriate lang and dir attributes
825            $out .= Html::openElement( 'tr', [
826                // Add an anchor so docs can link easily to the version of
827                // this specific library
828                'id' => Sanitizer::escapeIdForAttribute(
829                    "mw-version-library-$name"
830                ) ] )
831                . Html::rawElement(
832                    'td',
833                    [],
834                    $this->getLinkRenderer()->makeExternalLink(
835                        $info['homepage'],
836                        $info['name'],
837                        $this->getFullTitle(),
838                        '',
839                        [ 'class' => 'mw-version-library-name' ]
840                    )
841                )
842                . Html::element( 'td', [ 'dir' => 'auto' ], $info['version'] )
843                . Html::element( 'td', [ 'dir' => 'auto' ], $info['license'] )
844                . Html::element( 'td', [ 'dir' => 'auto' ], $info['authors'] ?? '—' )
845                // @phan-suppress-next-line SecurityCheck-DoubleEscaped See FIXME in listToText
846                . Html::element( 'td', [ 'dir' => 'auto' ], $this->listToText( $info['source'] ) )
847                . Html::closeElement( 'tr' );
848        }
849        $out .= Html::closeElement( 'table' );
850
851        return $out;
852    }
853
854    /**
855     * Obtains a list of installed parser tags and the associated H2 header
856     *
857     * @return string HTML output
858     */
859    protected function getParserTags() {
860        $tags = $this->parserFactory->getMainInstance()->getTags();
861        if ( !$tags ) {
862            return '';
863        }
864
865        $this->addTocSection( 'version-parser-extensiontags', 'mw-version-parser-extensiontags' );
866
867        $out = Html::rawElement(
868            'h2',
869            [ 'id' => 'mw-version-parser-extensiontags' ],
870            Html::rawElement(
871                'span',
872                [ 'class' => 'plainlinks' ],
873                $this->getLinkRenderer()->makeExternalLink(
874                    'https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Tag_extensions',
875                    $this->msg( 'version-parser-extensiontags' ),
876                    $this->getFullTitle()
877                )
878            )
879        );
880
881        array_walk( $tags, static function ( &$value ) {
882            // Bidirectional isolation improves readability in RTL wikis
883            $value = Html::rawElement(
884                'bdi',
885                // Prevent < and > from slipping to another line
886                [
887                    'style' => 'white-space: nowrap;',
888                ],
889                Html::element( 'code', [], "<$value>" )
890            );
891        } );
892
893        $out .= $this->listToText( $tags );
894
895        return $out;
896    }
897
898    /**
899     * Obtains a list of installed parser function hooks and the associated H2 header
900     *
901     * @return string HTML output
902     */
903    protected function getParserFunctionHooks() {
904        $funcHooks = $this->parserFactory->getMainInstance()->getFunctionHooks();
905        if ( !$funcHooks ) {
906            return '';
907        }
908
909        $this->addTocSection( 'version-parser-function-hooks', 'mw-version-parser-function-hooks' );
910
911        $out = Html::rawElement(
912            'h2',
913            [ 'id' => 'mw-version-parser-function-hooks' ],
914            Html::rawElement(
915                'span',
916                [ 'class' => 'plainlinks' ],
917                $this->getLinkRenderer()->makeExternalLink(
918                    'https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Parser_functions',
919                    $this->msg( 'version-parser-function-hooks' ),
920                    $this->getFullTitle()
921                )
922            )
923        );
924
925        $funcSynonyms = $this->parserFactory->getMainInstance()->getFunctionSynonyms();
926        // This will give us the preferred synonyms in the content language, as if
927        // we used MagicWord::getSynonym( 0 ), because they appear first in the arrays.
928        // We can't use MagicWord directly, because only Parser knows whether a function
929        // uses the leading "#" or not. Case-sensitive functions ("1") win over
930        // case-insensitive ones ("0"), like in Parser::callParserFunction().
931        // There should probably be a better API for this.
932        $preferredSynonyms = array_flip( array_reverse( $funcSynonyms[1] + $funcSynonyms[0] ) );
933        array_walk( $funcHooks, static function ( &$value ) use ( $preferredSynonyms ) {
934            $value = $preferredSynonyms[$value];
935        } );
936
937        // Sort case-insensitively, ignoring the leading '#' if present
938        usort( $funcHooks, static function ( $a, $b ) {
939            return strcasecmp( ltrim( $a, '#' ), ltrim( $b, '#' ) );
940        } );
941
942        array_walk( $funcHooks, static function ( &$value ) {
943            // Bidirectional isolation ensures it displays as {{#ns}} and not {{ns#}} in RTL wikis
944            $value = Html::rawElement(
945                'bdi',
946                [],
947                Html::element( 'code', [], '{{' . $value . '}}' )
948            );
949        } );
950
951        $out .= $this->getLanguage()->listToText( $funcHooks );
952
953        return $out;
954    }
955
956    /**
957     * Creates and returns the HTML for a single extension category.
958     *
959     * @since 1.17
960     * @param string $type
961     * @param string|null $text
962     * @param array $creditsGroup
963     * @return string
964     */
965    protected function getExtensionCategory( $type, ?string $text, array $creditsGroup ) {
966        $out = '';
967
968        if ( $creditsGroup ) {
969            $out .= $this->openExtType( $text, 'credits-' . $type );
970
971            usort( $creditsGroup, [ $this, 'compare' ] );
972
973            foreach ( $creditsGroup as $extension ) {
974                $out .= $this->getCreditsForExtension( $type, $extension );
975            }
976
977            $out .= Html::closeElement( 'table' );
978        }
979
980        return $out;
981    }
982
983    /**
984     * Callback to sort extensions by type.
985     * @param array $a
986     * @param array $b
987     * @return int
988     */
989    public function compare( $a, $b ) {
990        return $this->getLanguage()->lc( $a['name'] ) <=> $this->getLanguage()->lc( $b['name'] );
991    }
992
993    /**
994     * Creates and formats a version line for a single extension.
995     *
996     * Information for five columns will be created. Parameters required in the
997     * $extension array for part rendering are indicated in ()
998     *  - The name of (name), and URL link to (url), the extension
999     *  - Official version number (version) and if available version control system
1000     *    revision (path), link, and date
1001     *  - If available the short name of the license (license-name) and a link
1002     *    to ((LICENSE)|(COPYING))(\.txt)? if it exists.
1003     *  - Description of extension (descriptionmsg or description)
1004     *  - List of authors (author) and link to a ((AUTHORS)|(CREDITS))(\.txt)? file if it exists
1005     *
1006     * @param string $type Category name of the extension
1007     * @param array $extension
1008     *
1009     * @return string Raw HTML
1010     */
1011    public function getCreditsForExtension( $type, array $extension ) {
1012        $out = $this->getOutput();
1013
1014        // We must obtain the information for all the bits and pieces!
1015        // ... such as extension names and links
1016        if ( isset( $extension['namemsg'] ) ) {
1017            // Localized name of extension
1018            $extensionName = $this->msg( $extension['namemsg'] );
1019        } elseif ( isset( $extension['name'] ) ) {
1020            // Non localized version
1021            $extensionName = $extension['name'];
1022        } else {
1023            $extensionName = $this->msg( 'version-no-ext-name' );
1024        }
1025
1026        if ( isset( $extension['url'] ) ) {
1027            $extensionNameLink = $this->getLinkRenderer()->makeExternalLink(
1028                $extension['url'],
1029                $extensionName,
1030                $this->getFullTitle(),
1031                '',
1032                [ 'class' => 'mw-version-ext-name' ]
1033            );
1034        } else {
1035            $extensionNameLink = htmlspecialchars( $extensionName );
1036        }
1037
1038        // ... and the version information
1039        // If the extension path is set we will check that directory for GIT
1040        // metadata in an attempt to extract date and vcs commit metadata.
1041        $canonicalVersion = '&ndash;';
1042        $extensionPath = null;
1043        $vcsVersion = null;
1044        $vcsLink = null;
1045        $vcsDate = null;
1046
1047        if ( isset( $extension['version'] ) ) {
1048            $canonicalVersion = $out->parseInlineAsInterface( $extension['version'] );
1049        }
1050
1051        if ( isset( $extension['path'] ) ) {
1052            $extensionPath = dirname( $extension['path'] );
1053            if ( $this->coreId == '' ) {
1054                wfDebug( 'Looking up core head id' );
1055                $coreHeadSHA1 = GitInfo::repo()->getHeadSHA1();
1056                if ( $coreHeadSHA1 ) {
1057                    $this->coreId = $coreHeadSHA1;
1058                }
1059            }
1060            $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getInstance( CACHE_ANYTHING );
1061            $memcKey = $cache->makeKey(
1062                'specialversion-ext-version-text', $extension['path'], $this->coreId
1063            );
1064            [ $vcsVersion, $vcsLink, $vcsDate ] = $cache->get( $memcKey );
1065
1066            if ( !$vcsVersion ) {
1067                wfDebug( "Getting VCS info for extension {$extension['name']}" );
1068                $gitInfo = new GitInfo( $extensionPath );
1069                $vcsVersion = $gitInfo->getHeadSHA1();
1070                if ( $vcsVersion !== false ) {
1071                    $vcsVersion = substr( $vcsVersion, 0, 7 );
1072                    $vcsLink = $gitInfo->getHeadViewUrl();
1073                    $vcsDate = $gitInfo->getHeadCommitDate();
1074                }
1075                $cache->set( $memcKey, [ $vcsVersion, $vcsLink, $vcsDate ], 60 * 60 * 24 );
1076            } else {
1077                wfDebug( "Pulled VCS info for extension {$extension['name']} from cache" );
1078            }
1079        }
1080
1081        $versionString = Html::rawElement(
1082            'span',
1083            [ 'class' => 'mw-version-ext-version' ],
1084            $canonicalVersion
1085        );
1086
1087        if ( $vcsVersion ) {
1088            if ( $vcsLink ) {
1089                $vcsVerString = $this->getLinkRenderer()->makeExternalLink(
1090                    $vcsLink,
1091                    $this->msg( 'version-version', $vcsVersion ),
1092                    $this->getFullTitle(),
1093                    '',
1094                    [ 'class' => 'mw-version-ext-vcs-version' ]
1095                );
1096            } else {
1097                $vcsVerString = Html::element( 'span',
1098                    [ 'class' => 'mw-version-ext-vcs-version' ],
1099                    "({$vcsVersion})"
1100                );
1101            }
1102            $versionString .= " {$vcsVerString}";
1103
1104            if ( $vcsDate ) {
1105                $versionString .= ' ' . Html::element( 'span', [
1106                    'class' => 'mw-version-ext-vcs-timestamp',
1107                    'dir' => $this->getLanguage()->getDir(),
1108                ], $this->getLanguage()->timeanddate( $vcsDate, true ) );
1109            }
1110            $versionString = Html::rawElement( 'span',
1111                [ 'class' => 'mw-version-ext-meta-version' ],
1112                $versionString
1113            );
1114        }
1115
1116        // ... and license information; if a license file exists we
1117        // will link to it
1118        $licenseLink = '';
1119        if ( isset( $extension['name'] ) ) {
1120            $licenseName = null;
1121            if ( isset( $extension['license-name'] ) ) {
1122                $licenseName = new HtmlArmor( $out->parseInlineAsInterface( $extension['license-name'] ) );
1123            } elseif ( $extensionPath !== null && ExtensionInfo::getLicenseFileNames( $extensionPath ) ) {
1124                $licenseName = $this->msg( 'version-ext-license' )->text();
1125            }
1126            if ( $licenseName !== null ) {
1127                $licenseLink = $this->getLinkRenderer()->makeLink(
1128                    $this->getPageTitle( 'License/' . $extension['name'] ),
1129                    $licenseName,
1130                    [
1131                        'class' => 'mw-version-ext-license',
1132                        'dir' => 'auto',
1133                    ]
1134                );
1135            }
1136        }
1137
1138        // ... and generate the description; which can be a parameterized l10n message
1139        // in the form [ <msgname>, <parameter>, <parameter>... ] or just a straight
1140        // up string
1141        if ( isset( $extension['descriptionmsg'] ) ) {
1142            // Localized description of extension
1143            $descriptionMsg = $extension['descriptionmsg'];
1144
1145            if ( is_array( $descriptionMsg ) ) {
1146                $descriptionMsgKey = array_shift( $descriptionMsg );
1147                $descriptionMsg = array_map( 'htmlspecialchars', $descriptionMsg );
1148                $description = $this->msg( $descriptionMsgKey, ...$descriptionMsg )->text();
1149            } else {
1150                $description = $this->msg( $descriptionMsg )->text();
1151            }
1152        } elseif ( isset( $extension['description'] ) ) {
1153            // Non localized version
1154            $description = $extension['description'];
1155        } else {
1156            $description = '';
1157        }
1158        $description = $out->parseInlineAsInterface( $description );
1159
1160        // ... now get the authors for this extension
1161        $authors = $extension['author'] ?? [];
1162        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable path is set when there is a name
1163        $authors = $this->listAuthors( $authors, $extension['name'], $extensionPath );
1164
1165        // Finally! Create the table
1166        $html = Html::openElement( 'tr', [
1167                'class' => 'mw-version-ext',
1168                'id' => Sanitizer::escapeIdForAttribute( 'mw-version-ext-' . $type . '-' . $extension['name'] )
1169            ]
1170        );
1171
1172        $html .= Html::rawElement( 'td', [], $extensionNameLink );
1173        $html .= Html::rawElement( 'td', [], $versionString );
1174        $html .= Html::rawElement( 'td', [], $licenseLink );
1175        $html .= Html::rawElement( 'td', [ 'class' => 'mw-version-ext-description' ], $description );
1176        $html .= Html::rawElement( 'td', [ 'class' => 'mw-version-ext-authors' ], $authors );
1177
1178        $html .= Html::closeElement( 'tr' );
1179
1180        return $html;
1181    }
1182
1183    /**
1184     * Generate HTML showing hooks in $wgHooks.
1185     *
1186     * @return string HTML
1187     */
1188    private function getHooks() {
1189        if ( !$this->getConfig()->get( MainConfigNames::SpecialVersionShowHooks ) ) {
1190            return '';
1191        }
1192
1193        $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
1194        $hookNames = $hookContainer->getHookNames();
1195
1196        if ( !$hookNames ) {
1197            return '';
1198        }
1199
1200        sort( $hookNames );
1201
1202        $ret = [];
1203        $this->addTocSection( 'version-hooks', 'mw-version-hooks' );
1204        $ret[] = Html::element(
1205            'h2',
1206            [ 'id' => 'mw-version-hooks' ],
1207            $this->msg( 'version-hooks' )->text()
1208        );
1209        $ret[] = Html::openElement( 'table', [ 'class' => 'wikitable', 'id' => 'sv-hooks' ] );
1210        $ret[] = Html::openElement( 'tr' );
1211        $ret[] = Html::element( 'th', [], $this->msg( 'version-hook-name' )->text() );
1212        $ret[] = Html::element( 'th', [], $this->msg( 'version-hook-subscribedby' )->text() );
1213        $ret[] = Html::closeElement( 'tr' );
1214
1215        foreach ( $hookNames as $name ) {
1216            $handlers = $hookContainer->getHandlerDescriptions( $name );
1217
1218            $ret[] = Html::openElement( 'tr' );
1219            $ret[] = Html::element( 'td', [], $name );
1220            // @phan-suppress-next-line SecurityCheck-DoubleEscaped See FIXME in listToText
1221            $ret[] = Html::element( 'td', [], $this->listToText( $handlers ) );
1222            $ret[] = Html::closeElement( 'tr' );
1223        }
1224
1225        $ret[] = Html::closeElement( 'table' );
1226
1227        return implode( "\n", $ret );
1228    }
1229
1230    private function openExtType( string $text = null, string $name = null ) {
1231        $out = '';
1232
1233        $opt = [ 'class' => 'wikitable plainlinks mw-installed-software' ];
1234
1235        if ( $name ) {
1236            $opt['id'] = "sv-$name";
1237        }
1238
1239        $out .= Html::openElement( 'table', $opt );
1240
1241        if ( $text !== null ) {
1242            $out .= Html::element( 'caption', [], $text );
1243        }
1244
1245        if ( $name && $text !== null ) {
1246            $this->addTocSubSection( $text, "sv-$name" );
1247        }
1248
1249        $firstHeadingMsg = ( $name === 'credits-skin' )
1250            ? 'version-skin-colheader-name'
1251            : 'version-ext-colheader-name';
1252
1253        $out .= $this->getTableHeaderHtml( [
1254            $this->msg( $firstHeadingMsg )->text(),
1255            $this->msg( 'version-ext-colheader-version' )->text(),
1256            $this->msg( 'version-ext-colheader-license' )->text(),
1257            $this->msg( 'version-ext-colheader-description' )->text(),
1258            $this->msg( 'version-ext-colheader-credits' )->text()
1259        ] );
1260
1261        return $out;
1262    }
1263
1264    /**
1265     * Return HTML for a table header with given texts in header cells
1266     *
1267     * Includes thead element and scope="col" attribute for improved accessibility
1268     *
1269     * @param string|array $headers
1270     * @return string HTML
1271     */
1272    private function getTableHeaderHtml( $headers ): string {
1273        $out = '';
1274        $out .= Html::openElement( 'thead' );
1275        $out .= Html::openElement( 'tr' );
1276        foreach ( $headers as $header ) {
1277            $out .= Html::element( 'th', [ 'scope' => 'col' ], $header );
1278        }
1279        $out .= Html::closeElement( 'tr' );
1280        $out .= Html::closeElement( 'thead' );
1281        return $out;
1282    }
1283
1284    /**
1285     * Get information about client's IP address.
1286     *
1287     * @return string HTML fragment
1288     */
1289    private function IPInfo() {
1290        $ip = str_replace( '--', ' - ', htmlspecialchars( $this->getRequest()->getIP() ) );
1291
1292        return "<!-- visited from $ip -->\n<span style='display:none'>visited from $ip</span>";
1293    }
1294
1295    /**
1296     * Return a formatted unsorted list of authors
1297     *
1298     * 'And Others'
1299     *   If an item in the $authors array is '...' it is assumed to indicate an
1300     *   'and others' string which will then be linked to an ((AUTHORS)|(CREDITS))(\.txt)?
1301     *   file if it exists in $dir.
1302     *
1303     *   Similarly an entry ending with ' ...]' is assumed to be a link to an
1304     *   'and others' page.
1305     *
1306     *   If no '...' string variant is found, but an authors file is found an
1307     *   'and others' will be added to the end of the credits.
1308     *
1309     * @param string|array $authors
1310     * @param string|bool $extName Name of the extension for link creation,
1311     *   false if no links should be created
1312     * @param string $extDir Path to the extension root directory
1313     * @return string HTML fragment
1314     */
1315    public function listAuthors( $authors, $extName, $extDir ): string {
1316        $hasOthers = false;
1317        $linkRenderer = $this->getLinkRenderer();
1318
1319        $list = [];
1320        $authors = (array)$authors;
1321
1322        // Special case: if the authors array has only one item and it is "...",
1323        // it should not be rendered as the "version-poweredby-others" i18n msg,
1324        // but rather as "version-poweredby-various" i18n msg instead.
1325        if ( count( $authors ) === 1 && $authors[0] === '...' ) {
1326            // Link to the extension's or skin's AUTHORS or CREDITS file, if there is
1327            // such a file; otherwise just return the i18n msg as-is
1328            if ( $extName && ExtensionInfo::getAuthorsFileName( $extDir ) ) {
1329                return $linkRenderer->makeLink(
1330                    $this->getPageTitle( "Credits/$extName" ),
1331                    $this->msg( 'version-poweredby-various' )->text()
1332                );
1333            } else {
1334                return $this->msg( 'version-poweredby-various' )->escaped();
1335            }
1336        }
1337
1338        // Otherwise, if we have an actual array that has more than one item,
1339        // process each array item as usual
1340        foreach ( $authors as $item ) {
1341            if ( $item instanceof HtmlArmor ) {
1342                $list[] = HtmlArmor::getHtml( $item );
1343            } elseif ( $item === '...' ) {
1344                $hasOthers = true;
1345
1346                if ( $extName && ExtensionInfo::getAuthorsFileName( $extDir ) ) {
1347                    $text = $linkRenderer->makeLink(
1348                        $this->getPageTitle( "Credits/$extName" ),
1349                        $this->msg( 'version-poweredby-others' )->text()
1350                    );
1351                } else {
1352                    $text = $this->msg( 'version-poweredby-others' )->escaped();
1353                }
1354                $list[] = $text;
1355            } elseif ( str_ends_with( $item, ' ...]' ) ) {
1356                $hasOthers = true;
1357                $list[] = $this->getOutput()->parseInlineAsInterface(
1358                    substr( $item, 0, -4 ) . $this->msg( 'version-poweredby-others' )->text() . "]"
1359                );
1360            } else {
1361                $list[] = $this->getOutput()->parseInlineAsInterface( $item );
1362            }
1363        }
1364
1365        if ( $extName && !$hasOthers && ExtensionInfo::getAuthorsFileName( $extDir ) ) {
1366            $list[] = $linkRenderer->makeLink(
1367                $this->getPageTitle( "Credits/$extName" ),
1368                $this->msg( 'version-poweredby-others' )->text()
1369            );
1370        }
1371
1372        return $this->listToText( $list, false );
1373    }
1374
1375    /**
1376     * Convert an array of items into a list for display.
1377     *
1378     * @param array $list List of elements to display
1379     * @param bool $sort Whether to sort the items in $list
1380     * @return string
1381     * @fixme This method does not handle escaping consistently. Language::listToText expects all list elements to be
1382     * already escaped. However, self::arrayToString escapes some elements, but not others.
1383     */
1384    private function listToText( array $list, bool $sort = true ): string {
1385        if ( !$list ) {
1386            return '';
1387        }
1388        if ( $sort ) {
1389            sort( $list );
1390        }
1391
1392        return $this->getLanguage()
1393            ->listToText( array_map( [ __CLASS__, 'arrayToString' ], $list ) );
1394    }
1395
1396    /**
1397     * Convert an array or object to a string for display.
1398     *
1399     * @internal For use by ApiQuerySiteinfo (TODO: Turn into more stable method)
1400     * @param mixed $list Will convert an array to string if given and return
1401     *   the parameter unaltered otherwise
1402     * @return mixed
1403     * @fixme This should handle escaping more consistently, see FIXME in listToText
1404     */
1405    public static function arrayToString( $list ) {
1406        if ( is_array( $list ) && count( $list ) == 1 ) {
1407            $list = $list[0];
1408        }
1409        if ( $list instanceof Closure ) {
1410            // Don't output stuff like "Closure$;1028376090#8$48499d94fe0147f7c633b365be39952b$"
1411            return 'Closure';
1412        } elseif ( is_object( $list ) ) {
1413            return wfMessage( 'parentheses' )->params( get_class( $list ) )->escaped();
1414        } elseif ( !is_array( $list ) ) {
1415            return $list;
1416        } else {
1417            if ( is_object( $list[0] ) ) {
1418                $class = get_class( $list[0] );
1419            } else {
1420                $class = $list[0];
1421            }
1422
1423            return wfMessage( 'parentheses' )->params( "$class{$list[1]}" )->escaped();
1424        }
1425    }
1426
1427    /**
1428     * @deprecated since 1.41 Use GitInfo::repo() for MW_INSTALL_PATH, or new GitInfo otherwise.
1429     * @param string $dir Directory of the git checkout
1430     * @return string|false Sha1 of commit HEAD points to
1431     */
1432    public static function getGitHeadSha1( $dir ) {
1433        wfDeprecated( __METHOD__, '1.41' );
1434        return ( new GitInfo( $dir ) )->getHeadSHA1();
1435    }
1436
1437    /**
1438     * Get the list of entry points and their URLs
1439     * @return string HTML
1440     */
1441    public function getEntryPointInfo() {
1442        $config = $this->getConfig();
1443        $scriptPath = $config->get( MainConfigNames::ScriptPath ) ?: '/';
1444
1445        $entryPoints = [
1446            'version-entrypoints-articlepath' => $config->get( MainConfigNames::ArticlePath ),
1447            'version-entrypoints-scriptpath' => $scriptPath,
1448            'version-entrypoints-index-php' => wfScript( 'index' ),
1449            'version-entrypoints-api-php' => wfScript( 'api' ),
1450            'version-entrypoints-rest-php' => wfScript( 'rest' ),
1451        ];
1452
1453        $language = $this->getLanguage();
1454        $thAttributes = [
1455            'dir' => $language->getDir(),
1456            'lang' => $language->getHtmlCode(),
1457            'scope' => 'col'
1458        ];
1459
1460        $this->addTocSection( 'version-entrypoints', 'mw-version-entrypoints' );
1461
1462        $out = Html::element(
1463                'h2',
1464                [ 'id' => 'mw-version-entrypoints' ],
1465                $this->msg( 'version-entrypoints' )->text()
1466            ) .
1467            Html::openElement( 'table',
1468                [
1469                    'class' => 'wikitable plainlinks',
1470                    'id' => 'mw-version-entrypoints-table',
1471                    'dir' => 'ltr',
1472                    'lang' => 'en'
1473                ]
1474            ) .
1475            Html::openElement( 'thead' ) .
1476            Html::openElement( 'tr' ) .
1477            Html::element(
1478                'th',
1479                $thAttributes,
1480                $this->msg( 'version-entrypoints-header-entrypoint' )->text()
1481            ) .
1482            Html::element(
1483                'th',
1484                $thAttributes,
1485                $this->msg( 'version-entrypoints-header-url' )->text()
1486            ) .
1487            Html::closeElement( 'tr' ) .
1488            Html::closeElement( 'thead' );
1489
1490        foreach ( $entryPoints as $message => $value ) {
1491            $url = $this->urlUtils->expand( $value, PROTO_RELATIVE );
1492            $out .= Html::openElement( 'tr' ) .
1493                Html::rawElement( 'td', [], $this->msg( $message )->parse() ) .
1494                Html::rawElement( 'td', [],
1495                    Html::rawElement(
1496                        'code',
1497                        [],
1498                        $this->msg( new RawMessage( "[$url $value]" ) )->parse()
1499                    )
1500                ) .
1501                Html::closeElement( 'tr' );
1502        }
1503
1504        $out .= Html::closeElement( 'table' );
1505
1506        return $out;
1507    }
1508
1509    protected function getGroupName() {
1510        return 'wiki';
1511    }
1512}
1513
1514/**
1515 * Retain the old class name for backwards compatibility.
1516 * @deprecated since 1.41
1517 */
1518class_alias( SpecialVersion::class, 'SpecialVersion' );