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