Merge "StubObject: Use 'self' instead of 'StubObject' (or <ClassName>)"
[lhc/web/wiklou.git] / includes / specials / SpecialVersion.php
1 <?php
2 /**
3 * Implements Special:Version
4 *
5 * Copyright © 2005 Ævar Arnfjörð Bjarmason
6 *
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License along
18 * with this program; if not, write to the Free Software Foundation, Inc.,
19 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 * http://www.gnu.org/copyleft/gpl.html
21 *
22 * @file
23 * @ingroup SpecialPage
24 */
25
26 use MediaWiki\MediaWikiServices;
27
28 /**
29 * Give information about the version of MediaWiki, PHP, the DB and extensions
30 *
31 * @ingroup SpecialPage
32 */
33 class SpecialVersion extends SpecialPage {
34 protected $firstExtOpened = false;
35
36 /**
37 * Stores the current rev id/SHA hash of MediaWiki core
38 */
39 protected $coreId = '';
40
41 protected static $extensionTypes = false;
42
43 public function __construct() {
44 parent::__construct( 'Version' );
45 }
46
47 /**
48 * main()
49 * @param string|null $par
50 */
51 public function execute( $par ) {
52 global $IP, $wgExtensionCredits;
53
54 $this->setHeaders();
55 $this->outputHeader();
56 $out = $this->getOutput();
57 $out->allowClickjacking();
58
59 // Explode the sub page information into useful bits
60 $parts = explode( '/', (string)$par );
61 $extNode = null;
62 if ( isset( $parts[1] ) ) {
63 $extName = str_replace( '_', ' ', $parts[1] );
64 // Find it!
65 foreach ( $wgExtensionCredits as $group => $extensions ) {
66 foreach ( $extensions as $ext ) {
67 if ( isset( $ext['name'] ) && ( $ext['name'] === $extName ) ) {
68 $extNode = &$ext;
69 break 2;
70 }
71 }
72 }
73 if ( !$extNode ) {
74 $out->setStatusCode( 404 );
75 }
76 } else {
77 $extName = 'MediaWiki';
78 }
79
80 // Now figure out what to do
81 switch ( strtolower( $parts[0] ) ) {
82 case 'credits':
83 $out->addModuleStyles( 'mediawiki.special.version' );
84
85 $wikiText = '{{int:version-credits-not-found}}';
86 if ( $extName === 'MediaWiki' ) {
87 $wikiText = file_get_contents( $IP . '/CREDITS' );
88 // Put the contributor list into columns
89 $wikiText = str_replace(
90 [ '<!-- BEGIN CONTRIBUTOR LIST -->', '<!-- END CONTRIBUTOR LIST -->' ],
91 [ '<div class="mw-version-credits">', '</div>' ],
92 $wikiText );
93 } elseif ( ( $extNode !== null ) && isset( $extNode['path'] ) ) {
94 $file = $this->getExtAuthorsFileName( dirname( $extNode['path'] ) );
95 if ( $file ) {
96 $wikiText = file_get_contents( $file );
97 if ( substr( $file, -4 ) === '.txt' ) {
98 $wikiText = Html::element(
99 'pre',
100 [
101 'lang' => 'en',
102 'dir' => 'ltr',
103 ],
104 $wikiText
105 );
106 }
107 }
108 }
109
110 $out->setPageTitle( $this->msg( 'version-credits-title', $extName ) );
111 $out->addWikiTextAsInterface( $wikiText );
112 break;
113
114 case 'license':
115 $wikiText = '{{int:version-license-not-found}}';
116 if ( $extName === 'MediaWiki' ) {
117 $wikiText = file_get_contents( $IP . '/COPYING' );
118 } elseif ( ( $extNode !== null ) && isset( $extNode['path'] ) ) {
119 $file = $this->getExtLicenseFileName( dirname( $extNode['path'] ) );
120 if ( $file ) {
121 $wikiText = file_get_contents( $file );
122 $wikiText = Html::element(
123 'pre',
124 [
125 'lang' => 'en',
126 'dir' => 'ltr',
127 ],
128 $wikiText
129 );
130 }
131 }
132
133 $out->setPageTitle( $this->msg( 'version-license-title', $extName ) );
134 $out->addWikiTextAsInterface( $wikiText );
135 break;
136
137 default:
138 $out->addModuleStyles( 'mediawiki.special.version' );
139 $out->addWikiTextAsInterface(
140 $this->getMediaWikiCredits() .
141 $this->softwareInformation() .
142 $this->getEntryPointInfo()
143 );
144 $out->addHTML(
145 $this->getSkinCredits() .
146 $this->getExtensionCredits() .
147 $this->getExternalLibraries() .
148 $this->getParserTags() .
149 $this->getParserFunctionHooks()
150 );
151 $out->addWikiTextAsInterface( $this->getWgHooks() );
152 $out->addHTML( $this->IPInfo() );
153
154 break;
155 }
156 }
157
158 /**
159 * Returns wiki text showing the license information.
160 *
161 * @return string
162 */
163 private static function getMediaWikiCredits() {
164 $ret = Xml::element(
165 'h2',
166 [ 'id' => 'mw-version-license' ],
167 wfMessage( 'version-license' )->text()
168 );
169
170 // This text is always left-to-right.
171 $ret .= '<div class="plainlinks">';
172 $ret .= "__NOTOC__
173 " . self::getCopyrightAndAuthorList() . "\n
174 " . '<div class="mw-version-license-info">' .
175 wfMessage( 'version-license-info' )->text() .
176 '</div>';
177 $ret .= '</div>';
178
179 return str_replace( "\t\t", '', $ret ) . "\n";
180 }
181
182 /**
183 * Get the "MediaWiki is copyright 2001-20xx by lots of cool guys" text
184 *
185 * @return string
186 */
187 public static function getCopyrightAndAuthorList() {
188 global $wgLang;
189
190 if ( defined( 'MEDIAWIKI_INSTALL' ) ) {
191 $othersLink = '[https://www.mediawiki.org/wiki/Special:Version/Credits ' .
192 wfMessage( 'version-poweredby-others' )->text() . ']';
193 } else {
194 $othersLink = '[[Special:Version/Credits|' .
195 wfMessage( 'version-poweredby-others' )->text() . ']]';
196 }
197
198 $translatorsLink = '[https://translatewiki.net/wiki/Translating:MediaWiki/Credits ' .
199 wfMessage( 'version-poweredby-translators' )->text() . ']';
200
201 $authorList = [
202 'Magnus Manske', 'Brion Vibber', 'Lee Daniel Crocker',
203 'Tim Starling', 'Erik Möller', 'Gabriel Wicke', 'Ævar Arnfjörð Bjarmason',
204 'Niklas Laxström', 'Domas Mituzas', 'Rob Church', 'Yuri Astrakhan',
205 'Aryeh Gregor', 'Aaron Schulz', 'Andrew Garrett', 'Raimond Spekking',
206 'Alexandre Emsenhuber', 'Siebrand Mazeland', 'Chad Horohoe',
207 'Roan Kattouw', 'Trevor Parscal', 'Bryan Tong Minh', 'Sam Reed',
208 'Victor Vasiliev', 'Rotem Liss', 'Platonides', 'Antoine Musso',
209 'Timo Tijhof', 'Daniel Kinzler', 'Jeroen De Dauw', 'Brad Jorsch',
210 'Bartosz Dziewoński', 'Ed Sanders', 'Moriel Schottlender',
211 'Kunal Mehta', 'James D. Forrester', 'Brian Wolff', 'Adam Shorland',
212 $othersLink, $translatorsLink
213 ];
214
215 return wfMessage( 'version-poweredby-credits', MWTimestamp::getLocalInstance()->format( 'Y' ),
216 $wgLang->listToText( $authorList ) )->text();
217 }
218
219 /**
220 * Returns wiki text showing the third party software versions (apache, php, mysql).
221 *
222 * @return string
223 */
224 public static function softwareInformation() {
225 $dbr = wfGetDB( DB_REPLICA );
226
227 // Put the software in an array of form 'name' => 'version'. All messages should
228 // be loaded here, so feel free to use wfMessage in the 'name'. Raw HTML or
229 // wikimarkup can be used.
230 $software = [];
231 $software['[https://www.mediawiki.org/ MediaWiki]'] = self::getVersionLinked();
232 if ( wfIsHHVM() ) {
233 $software['[https://hhvm.com/ HHVM]'] = HHVM_VERSION . " (" . PHP_SAPI . ")";
234 } else {
235 $software['[https://php.net/ PHP]'] = PHP_VERSION . " (" . PHP_SAPI . ")";
236 }
237 $software[$dbr->getSoftwareLink()] = $dbr->getServerInfo();
238
239 if ( defined( 'INTL_ICU_VERSION' ) ) {
240 $software['[http://site.icu-project.org/ ICU]'] = INTL_ICU_VERSION;
241 }
242
243 // Allow a hook to add/remove items.
244 Hooks::run( 'SoftwareInfo', [ &$software ] );
245
246 $out = Xml::element(
247 'h2',
248 [ 'id' => 'mw-version-software' ],
249 wfMessage( 'version-software' )->text()
250 ) .
251 Xml::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-software' ] ) .
252 "<tr>
253 <th>" . wfMessage( 'version-software-product' )->text() . "</th>
254 <th>" . wfMessage( 'version-software-version' )->text() . "</th>
255 </tr>\n";
256
257 foreach ( $software as $name => $version ) {
258 $out .= "<tr>
259 <td>" . $name . "</td>
260 <td dir=\"ltr\">" . $version . "</td>
261 </tr>\n";
262 }
263
264 return $out . Xml::closeElement( 'table' );
265 }
266
267 /**
268 * Return a string of the MediaWiki version with Git revision if available.
269 *
270 * @param string $flags
271 * @param Language|string|null $lang
272 * @return mixed
273 */
274 public static function getVersion( $flags = '', $lang = null ) {
275 global $wgVersion, $IP;
276
277 $gitInfo = self::getGitHeadSha1( $IP );
278 if ( !$gitInfo ) {
279 $version = $wgVersion;
280 } elseif ( $flags === 'nodb' ) {
281 $shortSha1 = substr( $gitInfo, 0, 7 );
282 $version = "$wgVersion ($shortSha1)";
283 } else {
284 $shortSha1 = substr( $gitInfo, 0, 7 );
285 $msg = wfMessage( 'parentheses' );
286 if ( $lang !== null ) {
287 $msg->inLanguage( $lang );
288 }
289 $shortSha1 = $msg->params( $shortSha1 )->escaped();
290 $version = "$wgVersion $shortSha1";
291 }
292
293 return $version;
294 }
295
296 /**
297 * Return a wikitext-formatted string of the MediaWiki version with a link to
298 * the Git SHA1 of head if available.
299 * The fallback is just $wgVersion
300 *
301 * @return mixed
302 */
303 public static function getVersionLinked() {
304 global $wgVersion;
305
306 $gitVersion = self::getVersionLinkedGit();
307 if ( $gitVersion ) {
308 $v = $gitVersion;
309 } else {
310 $v = $wgVersion; // fallback
311 }
312
313 return $v;
314 }
315
316 /**
317 * @return string
318 */
319 private static function getwgVersionLinked() {
320 global $wgVersion;
321 $versionUrl = "";
322 if ( Hooks::run( 'SpecialVersionVersionUrl', [ $wgVersion, &$versionUrl ] ) ) {
323 $versionParts = [];
324 preg_match( "/^(\d+\.\d+)/", $wgVersion, $versionParts );
325 $versionUrl = "https://www.mediawiki.org/wiki/MediaWiki_{$versionParts[1]}";
326 }
327
328 return "[$versionUrl $wgVersion]";
329 }
330
331 /**
332 * @since 1.22 Returns the HEAD date in addition to the sha1 and link
333 * @return bool|string Global wgVersion + HEAD sha1 stripped to the first 7 chars
334 * with link and date, or false on failure
335 */
336 private static function getVersionLinkedGit() {
337 global $IP, $wgLang;
338
339 $gitInfo = new GitInfo( $IP );
340 $headSHA1 = $gitInfo->getHeadSHA1();
341 if ( !$headSHA1 ) {
342 return false;
343 }
344
345 $shortSHA1 = '(' . substr( $headSHA1, 0, 7 ) . ')';
346
347 $gitHeadUrl = $gitInfo->getHeadViewUrl();
348 if ( $gitHeadUrl !== false ) {
349 $shortSHA1 = "[$gitHeadUrl $shortSHA1]";
350 }
351
352 $gitHeadCommitDate = $gitInfo->getHeadCommitDate();
353 if ( $gitHeadCommitDate ) {
354 $shortSHA1 .= Html::element( 'br' ) . $wgLang->timeanddate( $gitHeadCommitDate, true );
355 }
356
357 return self::getwgVersionLinked() . " $shortSHA1";
358 }
359
360 /**
361 * Returns an array with the base extension types.
362 * Type is stored as array key, the message as array value.
363 *
364 * TODO: ideally this would return all extension types.
365 *
366 * @since 1.17
367 *
368 * @return array
369 */
370 public static function getExtensionTypes() {
371 if ( self::$extensionTypes === false ) {
372 self::$extensionTypes = [
373 'specialpage' => wfMessage( 'version-specialpages' )->text(),
374 'editor' => wfMessage( 'version-editors' )->text(),
375 'parserhook' => wfMessage( 'version-parserhooks' )->text(),
376 'variable' => wfMessage( 'version-variables' )->text(),
377 'media' => wfMessage( 'version-mediahandlers' )->text(),
378 'antispam' => wfMessage( 'version-antispam' )->text(),
379 'skin' => wfMessage( 'version-skins' )->text(),
380 'api' => wfMessage( 'version-api' )->text(),
381 'other' => wfMessage( 'version-other' )->text(),
382 ];
383
384 Hooks::run( 'ExtensionTypes', [ &self::$extensionTypes ] );
385 }
386
387 return self::$extensionTypes;
388 }
389
390 /**
391 * Returns the internationalized name for an extension type.
392 *
393 * @since 1.17
394 *
395 * @param string $type
396 *
397 * @return string
398 */
399 public static function getExtensionTypeName( $type ) {
400 $types = self::getExtensionTypes();
401
402 return $types[$type] ?? $types['other'];
403 }
404
405 /**
406 * Generate wikitext showing the name, URL, author and description of each extension.
407 *
408 * @return string Wikitext
409 */
410 public function getExtensionCredits() {
411 global $wgExtensionCredits;
412
413 if (
414 count( $wgExtensionCredits ) === 0 ||
415 // Skins are displayed separately, see getSkinCredits()
416 ( count( $wgExtensionCredits ) === 1 && isset( $wgExtensionCredits['skin'] ) )
417 ) {
418 return '';
419 }
420
421 $extensionTypes = self::getExtensionTypes();
422
423 $out = Xml::element(
424 'h2',
425 [ 'id' => 'mw-version-ext' ],
426 $this->msg( 'version-extensions' )->text()
427 ) .
428 Xml::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-ext' ] );
429
430 // Make sure the 'other' type is set to an array.
431 if ( !array_key_exists( 'other', $wgExtensionCredits ) ) {
432 $wgExtensionCredits['other'] = [];
433 }
434
435 // Find all extensions that do not have a valid type and give them the type 'other'.
436 foreach ( $wgExtensionCredits as $type => $extensions ) {
437 if ( !array_key_exists( $type, $extensionTypes ) ) {
438 $wgExtensionCredits['other'] = array_merge( $wgExtensionCredits['other'], $extensions );
439 }
440 }
441
442 $this->firstExtOpened = false;
443 // Loop through the extension categories to display their extensions in the list.
444 foreach ( $extensionTypes as $type => $message ) {
445 // Skins have a separate section
446 if ( $type !== 'other' && $type !== 'skin' ) {
447 $out .= $this->getExtensionCategory( $type, $message );
448 }
449 }
450
451 // We want the 'other' type to be last in the list.
452 $out .= $this->getExtensionCategory( 'other', $extensionTypes['other'] );
453
454 $out .= Xml::closeElement( 'table' );
455
456 return $out;
457 }
458
459 /**
460 * Generate wikitext showing the name, URL, author and description of each skin.
461 *
462 * @return string Wikitext
463 */
464 public function getSkinCredits() {
465 global $wgExtensionCredits;
466 if ( !isset( $wgExtensionCredits['skin'] ) || count( $wgExtensionCredits['skin'] ) === 0 ) {
467 return '';
468 }
469
470 $out = Xml::element(
471 'h2',
472 [ 'id' => 'mw-version-skin' ],
473 $this->msg( 'version-skins' )->text()
474 ) .
475 Xml::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-skin' ] );
476
477 $this->firstExtOpened = false;
478 $out .= $this->getExtensionCategory( 'skin', null );
479
480 $out .= Xml::closeElement( 'table' );
481
482 return $out;
483 }
484
485 /**
486 * Generate an HTML table for external libraries that are installed
487 *
488 * @return string
489 */
490 protected function getExternalLibraries() {
491 global $IP;
492 $path = "$IP/vendor/composer/installed.json";
493 if ( !file_exists( $path ) ) {
494 return '';
495 }
496
497 $installed = new ComposerInstalled( $path );
498 $out = Html::element(
499 'h2',
500 [ 'id' => 'mw-version-libraries' ],
501 $this->msg( 'version-libraries' )->text()
502 );
503 $out .= Html::openElement(
504 'table',
505 [ 'class' => 'wikitable plainlinks', 'id' => 'sv-libraries' ]
506 );
507 $out .= Html::openElement( 'tr' )
508 . Html::element( 'th', [], $this->msg( 'version-libraries-library' )->text() )
509 . Html::element( 'th', [], $this->msg( 'version-libraries-version' )->text() )
510 . Html::element( 'th', [], $this->msg( 'version-libraries-license' )->text() )
511 . Html::element( 'th', [], $this->msg( 'version-libraries-description' )->text() )
512 . Html::element( 'th', [], $this->msg( 'version-libraries-authors' )->text() )
513 . Html::closeElement( 'tr' );
514
515 foreach ( $installed->getInstalledDependencies() as $name => $info ) {
516 if ( strpos( $info['type'], 'mediawiki-' ) === 0 ) {
517 // Skip any extensions or skins since they'll be listed
518 // in their proper section
519 continue;
520 }
521 $authors = array_map( function ( $arr ) {
522 // If a homepage is set, link to it
523 if ( isset( $arr['homepage'] ) ) {
524 return "[{$arr['homepage']} {$arr['name']}]";
525 }
526 return $arr['name'];
527 }, $info['authors'] );
528 $authors = $this->listAuthors( $authors, false, "$IP/vendor/$name" );
529
530 // We can safely assume that the libraries' names and descriptions
531 // are written in English and aren't going to be translated,
532 // so set appropriate lang and dir attributes
533 $out .= Html::openElement( 'tr' )
534 . Html::rawElement(
535 'td',
536 [],
537 Linker::makeExternalLink(
538 "https://packagist.org/packages/$name", $name,
539 true, '',
540 [ 'class' => 'mw-version-library-name' ]
541 )
542 )
543 . Html::element( 'td', [ 'dir' => 'auto' ], $info['version'] )
544 . Html::element( 'td', [ 'dir' => 'auto' ], $this->listToText( $info['licenses'] ) )
545 . Html::element( 'td', [ 'lang' => 'en', 'dir' => 'ltr' ], $info['description'] )
546 . Html::rawElement( 'td', [], $authors )
547 . Html::closeElement( 'tr' );
548 }
549 $out .= Html::closeElement( 'table' );
550
551 return $out;
552 }
553
554 /**
555 * Obtains a list of installed parser tags and the associated H2 header
556 *
557 * @return string HTML output
558 */
559 protected function getParserTags() {
560 $tags = MediaWikiServices::getInstance()->getParser()->getTags();
561
562 if ( count( $tags ) ) {
563 $out = Html::rawElement(
564 'h2',
565 [
566 'class' => 'mw-headline plainlinks',
567 'id' => 'mw-version-parser-extensiontags',
568 ],
569 Linker::makeExternalLink(
570 'https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Tag_extensions',
571 $this->msg( 'version-parser-extensiontags' )->parse(),
572 false /* msg()->parse() already escapes */
573 )
574 );
575
576 array_walk( $tags, function ( &$value ) {
577 // Bidirectional isolation improves readability in RTL wikis
578 $value = Html::element(
579 'bdi',
580 // Prevent < and > from slipping to another line
581 [
582 'style' => 'white-space: nowrap;',
583 ],
584 "<$value>"
585 );
586 } );
587
588 $out .= $this->listToText( $tags );
589 } else {
590 $out = '';
591 }
592
593 return $out;
594 }
595
596 /**
597 * Obtains a list of installed parser function hooks and the associated H2 header
598 *
599 * @return string HTML output
600 */
601 protected function getParserFunctionHooks() {
602 $fhooks = MediaWikiServices::getInstance()->getParser()->getFunctionHooks();
603 if ( count( $fhooks ) ) {
604 $out = Html::rawElement(
605 'h2',
606 [
607 'class' => 'mw-headline plainlinks',
608 'id' => 'mw-version-parser-function-hooks',
609 ],
610 Linker::makeExternalLink(
611 'https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Parser_functions',
612 $this->msg( 'version-parser-function-hooks' )->parse(),
613 false /* msg()->parse() already escapes */
614 )
615 );
616
617 $out .= $this->listToText( $fhooks );
618 } else {
619 $out = '';
620 }
621
622 return $out;
623 }
624
625 /**
626 * Creates and returns the HTML for a single extension category.
627 *
628 * @since 1.17
629 *
630 * @param string $type
631 * @param string $message
632 *
633 * @return string
634 */
635 protected function getExtensionCategory( $type, $message ) {
636 global $wgExtensionCredits;
637
638 $out = '';
639
640 if ( array_key_exists( $type, $wgExtensionCredits ) && count( $wgExtensionCredits[$type] ) > 0 ) {
641 $out .= $this->openExtType( $message, 'credits-' . $type );
642
643 usort( $wgExtensionCredits[$type], [ $this, 'compare' ] );
644
645 foreach ( $wgExtensionCredits[$type] as $extension ) {
646 $out .= $this->getCreditsForExtension( $type, $extension );
647 }
648 }
649
650 return $out;
651 }
652
653 /**
654 * Callback to sort extensions by type.
655 * @param array $a
656 * @param array $b
657 * @return int
658 */
659 public function compare( $a, $b ) {
660 return $this->getLanguage()->lc( $a['name'] ) <=> $this->getLanguage()->lc( $b['name'] );
661 }
662
663 /**
664 * Creates and formats a version line for a single extension.
665 *
666 * Information for five columns will be created. Parameters required in the
667 * $extension array for part rendering are indicated in ()
668 * - The name of (name), and URL link to (url), the extension
669 * - Official version number (version) and if available version control system
670 * revision (path), link, and date
671 * - If available the short name of the license (license-name) and a link
672 * to ((LICENSE)|(COPYING))(\.txt)? if it exists.
673 * - Description of extension (descriptionmsg or description)
674 * - List of authors (author) and link to a ((AUTHORS)|(CREDITS))(\.txt)? file if it exists
675 *
676 * @param string $type Category name of the extension
677 * @param array $extension
678 *
679 * @return string Raw HTML
680 */
681 public function getCreditsForExtension( $type, array $extension ) {
682 $out = $this->getOutput();
683
684 // We must obtain the information for all the bits and pieces!
685 // ... such as extension names and links
686 if ( isset( $extension['namemsg'] ) ) {
687 // Localized name of extension
688 $extensionName = $this->msg( $extension['namemsg'] )->text();
689 } elseif ( isset( $extension['name'] ) ) {
690 // Non localized version
691 $extensionName = $extension['name'];
692 } else {
693 $extensionName = $this->msg( 'version-no-ext-name' )->text();
694 }
695
696 if ( isset( $extension['url'] ) ) {
697 $extensionNameLink = Linker::makeExternalLink(
698 $extension['url'],
699 $extensionName,
700 true,
701 '',
702 [ 'class' => 'mw-version-ext-name' ]
703 );
704 } else {
705 $extensionNameLink = htmlspecialchars( $extensionName );
706 }
707
708 // ... and the version information
709 // If the extension path is set we will check that directory for GIT
710 // metadata in an attempt to extract date and vcs commit metadata.
711 $canonicalVersion = '&ndash;';
712 $extensionPath = null;
713 $vcsVersion = null;
714 $vcsLink = null;
715 $vcsDate = null;
716
717 if ( isset( $extension['version'] ) ) {
718 $canonicalVersion = $out->parseInlineAsInterface( $extension['version'] );
719 }
720
721 if ( isset( $extension['path'] ) ) {
722 global $IP;
723 $extensionPath = dirname( $extension['path'] );
724 if ( $this->coreId == '' ) {
725 wfDebug( 'Looking up core head id' );
726 $coreHeadSHA1 = self::getGitHeadSha1( $IP );
727 if ( $coreHeadSHA1 ) {
728 $this->coreId = $coreHeadSHA1;
729 }
730 }
731 $cache = wfGetCache( CACHE_ANYTHING );
732 $memcKey = $cache->makeKey(
733 'specialversion-ext-version-text', $extension['path'], $this->coreId
734 );
735 list( $vcsVersion, $vcsLink, $vcsDate ) = $cache->get( $memcKey );
736
737 if ( !$vcsVersion ) {
738 wfDebug( "Getting VCS info for extension {$extension['name']}" );
739 $gitInfo = new GitInfo( $extensionPath );
740 $vcsVersion = $gitInfo->getHeadSHA1();
741 if ( $vcsVersion !== false ) {
742 $vcsVersion = substr( $vcsVersion, 0, 7 );
743 $vcsLink = $gitInfo->getHeadViewUrl();
744 $vcsDate = $gitInfo->getHeadCommitDate();
745 }
746 $cache->set( $memcKey, [ $vcsVersion, $vcsLink, $vcsDate ], 60 * 60 * 24 );
747 } else {
748 wfDebug( "Pulled VCS info for extension {$extension['name']} from cache" );
749 }
750 }
751
752 $versionString = Html::rawElement(
753 'span',
754 [ 'class' => 'mw-version-ext-version' ],
755 $canonicalVersion
756 );
757
758 if ( $vcsVersion ) {
759 if ( $vcsLink ) {
760 $vcsVerString = Linker::makeExternalLink(
761 $vcsLink,
762 $this->msg( 'version-version', $vcsVersion ),
763 true,
764 '',
765 [ 'class' => 'mw-version-ext-vcs-version' ]
766 );
767 } else {
768 $vcsVerString = Html::element( 'span',
769 [ 'class' => 'mw-version-ext-vcs-version' ],
770 "({$vcsVersion})"
771 );
772 }
773 $versionString .= " {$vcsVerString}";
774
775 if ( $vcsDate ) {
776 $vcsTimeString = Html::element( 'span',
777 [ 'class' => 'mw-version-ext-vcs-timestamp' ],
778 $this->getLanguage()->timeanddate( $vcsDate, true )
779 );
780 $versionString .= " {$vcsTimeString}";
781 }
782 $versionString = Html::rawElement( 'span',
783 [ 'class' => 'mw-version-ext-meta-version' ],
784 $versionString
785 );
786 }
787
788 // ... and license information; if a license file exists we
789 // will link to it
790 $licenseLink = '';
791 if ( isset( $extension['name'] ) ) {
792 $licenseName = null;
793 if ( isset( $extension['license-name'] ) ) {
794 $licenseName = new HtmlArmor( $out->parseInlineAsInterface( $extension['license-name'] ) );
795 } elseif ( $this->getExtLicenseFileName( $extensionPath ) ) {
796 $licenseName = $this->msg( 'version-ext-license' )->text();
797 }
798 if ( $licenseName !== null ) {
799 $licenseLink = $this->getLinkRenderer()->makeLink(
800 $this->getPageTitle( 'License/' . $extension['name'] ),
801 $licenseName,
802 [
803 'class' => 'mw-version-ext-license',
804 'dir' => 'auto',
805 ]
806 );
807 }
808 }
809
810 // ... and generate the description; which can be a parameterized l10n message
811 // in the form array( <msgname>, <parameter>, <parameter>... ) or just a straight
812 // up string
813 if ( isset( $extension['descriptionmsg'] ) ) {
814 // Localized description of extension
815 $descriptionMsg = $extension['descriptionmsg'];
816
817 if ( is_array( $descriptionMsg ) ) {
818 $descriptionMsgKey = $descriptionMsg[0]; // Get the message key
819 array_shift( $descriptionMsg ); // Shift out the message key to get the parameters only
820 array_map( "htmlspecialchars", $descriptionMsg ); // For sanity
821 $description = $this->msg( $descriptionMsgKey, $descriptionMsg )->text();
822 } else {
823 $description = $this->msg( $descriptionMsg )->text();
824 }
825 } elseif ( isset( $extension['description'] ) ) {
826 // Non localized version
827 $description = $extension['description'];
828 } else {
829 $description = '';
830 }
831 $description = $out->parseInlineAsInterface( $description );
832
833 // ... now get the authors for this extension
834 $authors = $extension['author'] ?? [];
835 $authors = $this->listAuthors( $authors, $extension['name'], $extensionPath );
836
837 // Finally! Create the table
838 $html = Html::openElement( 'tr', [
839 'class' => 'mw-version-ext',
840 'id' => Sanitizer::escapeIdForAttribute( 'mw-version-ext-' . $type . '-' . $extension['name'] )
841 ]
842 );
843
844 $html .= Html::rawElement( 'td', [], $extensionNameLink );
845 $html .= Html::rawElement( 'td', [], $versionString );
846 $html .= Html::rawElement( 'td', [], $licenseLink );
847 $html .= Html::rawElement( 'td', [ 'class' => 'mw-version-ext-description' ], $description );
848 $html .= Html::rawElement( 'td', [ 'class' => 'mw-version-ext-authors' ], $authors );
849
850 $html .= Html::closeElement( 'tr' );
851
852 return $html;
853 }
854
855 /**
856 * Generate wikitext showing hooks in $wgHooks.
857 *
858 * @return string Wikitext
859 */
860 private function getWgHooks() {
861 global $wgSpecialVersionShowHooks, $wgHooks;
862
863 if ( $wgSpecialVersionShowHooks && count( $wgHooks ) ) {
864 $myWgHooks = $wgHooks;
865 ksort( $myWgHooks );
866
867 $ret = [];
868 $ret[] = '== {{int:version-hooks}} ==';
869 $ret[] = Html::openElement( 'table', [ 'class' => 'wikitable', 'id' => 'sv-hooks' ] );
870 $ret[] = Html::openElement( 'tr' );
871 $ret[] = Html::element( 'th', [], $this->msg( 'version-hook-name' )->text() );
872 $ret[] = Html::element( 'th', [], $this->msg( 'version-hook-subscribedby' )->text() );
873 $ret[] = Html::closeElement( 'tr' );
874
875 foreach ( $myWgHooks as $hook => $hooks ) {
876 $ret[] = Html::openElement( 'tr' );
877 $ret[] = Html::element( 'td', [], $hook );
878 $ret[] = Html::element( 'td', [], $this->listToText( $hooks ) );
879 $ret[] = Html::closeElement( 'tr' );
880 }
881
882 $ret[] = Html::closeElement( 'table' );
883
884 return implode( "\n", $ret );
885 } else {
886 return '';
887 }
888 }
889
890 private function openExtType( $text = null, $name = null ) {
891 $out = '';
892
893 $opt = [ 'colspan' => 5 ];
894 if ( $this->firstExtOpened ) {
895 // Insert a spacing line
896 $out .= Html::rawElement( 'tr', [ 'class' => 'sv-space' ],
897 Html::element( 'td', $opt )
898 );
899 }
900 $this->firstExtOpened = true;
901
902 if ( $name ) {
903 $opt['id'] = "sv-$name";
904 }
905
906 if ( $text !== null ) {
907 $out .= Html::rawElement( 'tr', [],
908 Html::element( 'th', $opt, $text )
909 );
910 }
911
912 $firstHeadingMsg = ( $name === 'credits-skin' )
913 ? 'version-skin-colheader-name'
914 : 'version-ext-colheader-name';
915 $out .= Html::openElement( 'tr' );
916 $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ],
917 $this->msg( $firstHeadingMsg )->text() );
918 $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ],
919 $this->msg( 'version-ext-colheader-version' )->text() );
920 $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ],
921 $this->msg( 'version-ext-colheader-license' )->text() );
922 $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ],
923 $this->msg( 'version-ext-colheader-description' )->text() );
924 $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ],
925 $this->msg( 'version-ext-colheader-credits' )->text() );
926 $out .= Html::closeElement( 'tr' );
927
928 return $out;
929 }
930
931 /**
932 * Get information about client's IP address.
933 *
934 * @return string HTML fragment
935 */
936 private function IPInfo() {
937 $ip = str_replace( '--', ' - ', htmlspecialchars( $this->getRequest()->getIP() ) );
938
939 return "<!-- visited from $ip -->\n<span style='display:none'>visited from $ip</span>";
940 }
941
942 /**
943 * Return a formatted unsorted list of authors
944 *
945 * 'And Others'
946 * If an item in the $authors array is '...' it is assumed to indicate an
947 * 'and others' string which will then be linked to an ((AUTHORS)|(CREDITS))(\.txt)?
948 * file if it exists in $dir.
949 *
950 * Similarly an entry ending with ' ...]' is assumed to be a link to an
951 * 'and others' page.
952 *
953 * If no '...' string variant is found, but an authors file is found an
954 * 'and others' will be added to the end of the credits.
955 *
956 * @param string|array $authors
957 * @param string|bool $extName Name of the extension for link creation,
958 * false if no links should be created
959 * @param string $extDir Path to the extension root directory
960 *
961 * @return string HTML fragment
962 */
963 public function listAuthors( $authors, $extName, $extDir ) {
964 $hasOthers = false;
965 $linkRenderer = $this->getLinkRenderer();
966
967 $list = [];
968 foreach ( (array)$authors as $item ) {
969 if ( $item == '...' ) {
970 $hasOthers = true;
971
972 if ( $extName && $this->getExtAuthorsFileName( $extDir ) ) {
973 $text = $linkRenderer->makeLink(
974 $this->getPageTitle( "Credits/$extName" ),
975 $this->msg( 'version-poweredby-others' )->text()
976 );
977 } else {
978 $text = $this->msg( 'version-poweredby-others' )->escaped();
979 }
980 $list[] = $text;
981 } elseif ( substr( $item, -5 ) == ' ...]' ) {
982 $hasOthers = true;
983 $list[] = $this->getOutput()->parseInlineAsInterface(
984 substr( $item, 0, -4 ) . $this->msg( 'version-poweredby-others' )->text() . "]"
985 );
986 } else {
987 $list[] = $this->getOutput()->parseInlineAsInterface( $item );
988 }
989 }
990
991 if ( $extName && !$hasOthers && $this->getExtAuthorsFileName( $extDir ) ) {
992 $list[] = $text = $linkRenderer->makeLink(
993 $this->getPageTitle( "Credits/$extName" ),
994 $this->msg( 'version-poweredby-others' )->text()
995 );
996 }
997
998 return $this->listToText( $list, false );
999 }
1000
1001 /**
1002 * Obtains the full path of an extensions authors or credits file if
1003 * one exists.
1004 *
1005 * @param string $extDir Path to the extensions root directory
1006 *
1007 * @since 1.23
1008 *
1009 * @return bool|string False if no such file exists, otherwise returns
1010 * a path to it.
1011 */
1012 public static function getExtAuthorsFileName( $extDir ) {
1013 if ( !$extDir ) {
1014 return false;
1015 }
1016
1017 foreach ( scandir( $extDir ) as $file ) {
1018 $fullPath = $extDir . DIRECTORY_SEPARATOR . $file;
1019 if ( preg_match( '/^((AUTHORS)|(CREDITS))(\.txt|\.wiki|\.mediawiki)?$/', $file ) &&
1020 is_readable( $fullPath ) &&
1021 is_file( $fullPath )
1022 ) {
1023 return $fullPath;
1024 }
1025 }
1026
1027 return false;
1028 }
1029
1030 /**
1031 * Obtains the full path of an extensions copying or license file if
1032 * one exists.
1033 *
1034 * @param string $extDir Path to the extensions root directory
1035 *
1036 * @since 1.23
1037 *
1038 * @return bool|string False if no such file exists, otherwise returns
1039 * a path to it.
1040 */
1041 public static function getExtLicenseFileName( $extDir ) {
1042 if ( !$extDir ) {
1043 return false;
1044 }
1045
1046 foreach ( scandir( $extDir ) as $file ) {
1047 $fullPath = $extDir . DIRECTORY_SEPARATOR . $file;
1048 if ( preg_match( '/^((COPYING)|(LICENSE))(\.txt)?$/', $file ) &&
1049 is_readable( $fullPath ) &&
1050 is_file( $fullPath )
1051 ) {
1052 return $fullPath;
1053 }
1054 }
1055
1056 return false;
1057 }
1058
1059 /**
1060 * Convert an array of items into a list for display.
1061 *
1062 * @param array $list List of elements to display
1063 * @param bool $sort Whether to sort the items in $list
1064 *
1065 * @return string
1066 */
1067 public function listToText( $list, $sort = true ) {
1068 if ( !count( $list ) ) {
1069 return '';
1070 }
1071 if ( $sort ) {
1072 sort( $list );
1073 }
1074
1075 return $this->getLanguage()
1076 ->listToText( array_map( [ __CLASS__, 'arrayToString' ], $list ) );
1077 }
1078
1079 /**
1080 * Convert an array or object to a string for display.
1081 *
1082 * @param mixed $list Will convert an array to string if given and return
1083 * the parameter unaltered otherwise
1084 *
1085 * @return mixed
1086 */
1087 public static function arrayToString( $list ) {
1088 if ( is_array( $list ) && count( $list ) == 1 ) {
1089 $list = $list[0];
1090 }
1091 if ( $list instanceof Closure ) {
1092 // Don't output stuff like "Closure$;1028376090#8$48499d94fe0147f7c633b365be39952b$"
1093 return 'Closure';
1094 } elseif ( is_object( $list ) ) {
1095 $class = wfMessage( 'parentheses' )->params( get_class( $list ) )->escaped();
1096
1097 return $class;
1098 } elseif ( !is_array( $list ) ) {
1099 return $list;
1100 } else {
1101 if ( is_object( $list[0] ) ) {
1102 $class = get_class( $list[0] );
1103 } else {
1104 $class = $list[0];
1105 }
1106
1107 return wfMessage( 'parentheses' )->params( "$class, {$list[1]}" )->escaped();
1108 }
1109 }
1110
1111 /**
1112 * @param string $dir Directory of the git checkout
1113 * @return bool|string Sha1 of commit HEAD points to
1114 */
1115 public static function getGitHeadSha1( $dir ) {
1116 $repo = new GitInfo( $dir );
1117
1118 return $repo->getHeadSHA1();
1119 }
1120
1121 /**
1122 * @param string $dir Directory of the git checkout
1123 * @return bool|string Branch currently checked out
1124 */
1125 public static function getGitCurrentBranch( $dir ) {
1126 $repo = new GitInfo( $dir );
1127 return $repo->getCurrentBranch();
1128 }
1129
1130 /**
1131 * Get the list of entry points and their URLs
1132 * @return string Wikitext
1133 */
1134 public function getEntryPointInfo() {
1135 global $wgArticlePath, $wgScriptPath;
1136 $scriptPath = $wgScriptPath ?: "/";
1137 $entryPoints = [
1138 'version-entrypoints-articlepath' => $wgArticlePath,
1139 'version-entrypoints-scriptpath' => $scriptPath,
1140 'version-entrypoints-index-php' => wfScript( 'index' ),
1141 'version-entrypoints-api-php' => wfScript( 'api' ),
1142 'version-entrypoints-load-php' => wfScript( 'load' ),
1143 ];
1144
1145 $language = $this->getLanguage();
1146 $thAttribures = [
1147 'dir' => $language->getDir(),
1148 'lang' => $language->getHtmlCode()
1149 ];
1150 $out = Html::element(
1151 'h2',
1152 [ 'id' => 'mw-version-entrypoints' ],
1153 $this->msg( 'version-entrypoints' )->text()
1154 ) .
1155 Html::openElement( 'table',
1156 [
1157 'class' => 'wikitable plainlinks',
1158 'id' => 'mw-version-entrypoints-table',
1159 'dir' => 'ltr',
1160 'lang' => 'en'
1161 ]
1162 ) .
1163 Html::openElement( 'tr' ) .
1164 Html::element(
1165 'th',
1166 $thAttribures,
1167 $this->msg( 'version-entrypoints-header-entrypoint' )->text()
1168 ) .
1169 Html::element(
1170 'th',
1171 $thAttribures,
1172 $this->msg( 'version-entrypoints-header-url' )->text()
1173 ) .
1174 Html::closeElement( 'tr' );
1175
1176 foreach ( $entryPoints as $message => $value ) {
1177 $url = wfExpandUrl( $value, PROTO_RELATIVE );
1178 $out .= Html::openElement( 'tr' ) .
1179 // ->plain() looks like it should be ->parse(), but this function
1180 // returns wikitext, not HTML, boo
1181 Html::rawElement( 'td', [], $this->msg( $message )->plain() ) .
1182 Html::rawElement( 'td', [], Html::rawElement( 'code', [], "[$url $value]" ) ) .
1183 Html::closeElement( 'tr' );
1184 }
1185
1186 $out .= Html::closeElement( 'table' );
1187
1188 return $out;
1189 }
1190
1191 protected function getGroupName() {
1192 return 'wiki';
1193 }
1194 }