1<?php 2 3namespace MediaWiki\Extension\Interwiki; 4 5use Html; 6use HTMLForm; 7use Language; 8use LogPage; 9use MediaWiki\MediaWikiServices; 10use OutputPage; 11use PermissionsError; 12use ReadOnlyError; 13use SpecialPage; 14use Status; 15 16/** 17 * Implements Special:Interwiki 18 * @ingroup SpecialPage 19 */ 20class SpecialInterwiki extends SpecialPage { 21 /** 22 * Constructor - sets up the new special page 23 */ 24 public function __construct() { 25 parent::__construct( 'Interwiki' ); 26 } 27 28 public function doesWrites() { 29 return true; 30 } 31 32 /** 33 * Different description will be shown on Special:SpecialPage depending on 34 * whether the user can modify the data. 35 * 36 * @return string 37 */ 38 public function getDescription() { 39 return $this->msg( $this->canModify() ? 40 'interwiki' : 'interwiki-title-norights' )->plain(); 41 } 42 43 public function getSubpagesForPrefixSearch() { 44 // delete, edit both require the prefix parameter. 45 return [ 'add' ]; 46 } 47 48 /** 49 * Show the special page 50 * 51 * @param string|null $par parameter passed to the page or null 52 */ 53 public function execute( $par ) { 54 $this->setHeaders(); 55 $this->outputHeader(); 56 57 $out = $this->getOutput(); 58 $request = $this->getRequest(); 59 60 $out->addModuleStyles( 'ext.interwiki.specialpage' ); 61 62 $action = $par ?: $request->getVal( 'action', $par ); 63 64 if ( !in_array( $action, [ 'add', 'edit', 'delete' ] ) || !$this->canModify( $out ) ) { 65 $this->showList(); 66 } else { 67 $this->showForm( $action ); 68 } 69 } 70 71 /** 72 * Returns boolean whether the user can modify the data. 73 * @param OutputPage|bool $out If $wgOut object given, it adds the respective error message. 74 * @return bool 75 * @throws PermissionsError|ReadOnlyError 76 */ 77 public function canModify( $out = false ) { 78 global $wgInterwikiCache; 79 if ( !$this->getUser()->isAllowed( 'interwiki' ) ) { 80 // Check permissions 81 if ( $out ) { 82 throw new PermissionsError( 'interwiki' ); 83 } 84 85 return false; 86 } elseif ( $wgInterwikiCache ) { 87 // Editing the interwiki cache is not supported 88 if ( $out ) { 89 $out->addWikiMsg( 'interwiki-cached' ); 90 } 91 92 return false; 93 } else { 94 $this->checkReadOnly(); 95 } 96 97 return true; 98 } 99 100 /** 101 * @param string $action The action of the form 102 */ 103 protected function showForm( $action ) { 104 $formDescriptor = []; 105 $hiddenFields = [ 106 'action' => $action, 107 ]; 108 109 $status = Status::newGood(); 110 $request = $this->getRequest(); 111 $prefix = $request->getVal( 'prefix', $request->getVal( 'hiddenPrefix' ) ); 112 113 switch ( $action ) { 114 case 'add': 115 case 'edit': 116 $formDescriptor = [ 117 'prefix' => [ 118 'type' => 'text', 119 'label-message' => 'interwiki-prefix-label', 120 'name' => 'prefix', 121 'autofocus' => true, 122 ], 123 124 'local' => [ 125 'type' => 'check', 126 'id' => 'mw-interwiki-local', 127 'label-message' => 'interwiki-local-label', 128 'name' => 'local', 129 ], 130 131 'trans' => [ 132 'type' => 'check', 133 'id' => 'mw-interwiki-trans', 134 'label-message' => 'interwiki-trans-label', 135 'name' => 'trans', 136 ], 137 138 'url' => [ 139 'type' => 'url', 140 'id' => 'mw-interwiki-url', 141 'label-message' => 'interwiki-url-label', 142 'maxlength' => 200, 143 'name' => 'wpInterwikiURL', 144 'size' => 60, 145 ], 146 147 'reason' => [ 148 'type' => 'text', 149 'id' => "mw-interwiki-{$action}reason", 150 'label-message' => 'interwiki_reasonfield', 151 'maxlength' => 200, 152 'name' => 'wpInterwikiReason', 153 'size' => 60, 154 ], 155 ]; 156 157 break; 158 case 'delete': 159 $formDescriptor = [ 160 'prefix' => [ 161 'type' => 'hidden', 162 'name' => 'prefix', 163 'default' => $prefix, 164 ], 165 166 'reason' => [ 167 'type' => 'text', 168 'name' => 'reason', 169 'label-message' => 'interwiki_reasonfield', 170 ], 171 ]; 172 173 break; 174 } 175 176 $formDescriptor['hiddenPrefix'] = [ 177 'type' => 'hidden', 178 'name' => 'hiddenPrefix', 179 'default' => $prefix, 180 ]; 181 182 if ( $action === 'edit' ) { 183 $dbr = wfGetDB( DB_REPLICA ); 184 $row = $dbr->selectRow( 'interwiki', '*', [ 'iw_prefix' => $prefix ], __METHOD__ ); 185 186 $formDescriptor['prefix']['disabled'] = true; 187 $formDescriptor['prefix']['default'] = $prefix; 188 $hiddenFields['prefix'] = $prefix; 189 190 if ( !$row ) { 191 $status->fatal( 'interwiki_editerror', $prefix ); 192 } else { 193 $formDescriptor['url']['default'] = $row->iw_url; 194 $formDescriptor['url']['trans'] = $row->iw_trans; 195 $formDescriptor['url']['local'] = $row->iw_local; 196 } 197 } 198 199 if ( !$status->isOK() ) { 200 $formDescriptor = []; 201 } 202 203 $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); 204 $htmlForm 205 ->addHiddenFields( $hiddenFields ) 206 ->setSubmitCallback( [ $this, 'onSubmit' ] ); 207 208 if ( $status->isOK() ) { 209 if ( $action === 'delete' ) { 210 $htmlForm->setSubmitDestructive(); 211 } 212 213 $htmlForm->setSubmitTextMsg( $action !== 'add' ? $action : 'interwiki_addbutton' ) 214 ->setIntro( $this->msg( $action !== 'delete' ? "interwiki_{$action}intro" : 215 'interwiki_deleting', $prefix )->escaped() ) 216 ->show(); 217 } else { 218 $htmlForm->suppressDefaultSubmit() 219 ->prepareForm() 220 ->displayForm( $status ); 221 } 222 223 $this->getOutput()->addBacklinkSubtitle( $this->getPageTitle() ); 224 } 225 226 public function onSubmit( array $data ) { 227 global $wgInterwikiCentralInterlanguageDB; 228 229 $status = Status::newGood(); 230 $request = $this->getRequest(); 231 $prefix = $this->getRequest()->getVal( 'prefix', '' ); 232 $do = $request->getVal( 'action' ); 233 // Show an error if the prefix is invalid (only when adding one). 234 // Invalid characters for a title should also be invalid for a prefix. 235 // Whitespace, ':', '&' and '=' are invalid, too. 236 // (Bug 30599). 237 global $wgLegalTitleChars; 238 $validPrefixChars = preg_replace( '/[ :&=]/', '', $wgLegalTitleChars ); 239 if ( $do === 'add' && preg_match( "/\s|[^$validPrefixChars]/", $prefix ) ) { 240 $status->fatal( 'interwiki-badprefix', htmlspecialchars( $prefix ) ); 241 return $status; 242 } 243 // Disallow adding local interlanguage definitions if using global 244 if ( 245 $do === 'add' && Language::fetchLanguageName( $prefix ) 246 && $wgInterwikiCentralInterlanguageDB !== wfWikiID() 247 && $wgInterwikiCentralInterlanguageDB !== null 248 ) { 249 $status->fatal( 'interwiki-cannotaddlocallanguage', htmlspecialchars( $prefix ) ); 250 return $status; 251 } 252 $reason = $data['reason']; 253 $selfTitle = $this->getPageTitle(); 254 $lookup = MediaWikiServices::getInstance()->getInterwikiLookup(); 255 $dbw = wfGetDB( DB_MASTER ); 256 switch ( $do ) { 257 case 'delete': 258 $dbw->delete( 'interwiki', [ 'iw_prefix' => $prefix ], __METHOD__ ); 259 260 if ( $dbw->affectedRows() === 0 ) { 261 $status->fatal( 'interwiki_delfailed', $prefix ); 262 } else { 263 $this->getOutput()->addWikiMsg( 'interwiki_deleted', $prefix ); 264 $log = new LogPage( 'interwiki' ); 265 $log->addEntry( 266 'iw_delete', 267 $selfTitle, 268 $reason, 269 [ $prefix ], 270 $this->getUser() 271 ); 272 $lookup->invalidateCache( $prefix ); 273 } 274 break; 275 /** @noinspection PhpMissingBreakStatementInspection */ 276 case 'add': 277 $contLang = MediaWikiServices::getInstance()->getContentLanguage(); 278 $prefix = $contLang->lc( $prefix ); 279 case 'edit': 280 $theurl = $data['url']; 281 $local = $data['local'] ? 1 : 0; 282 $trans = $data['trans'] ? 1 : 0; 283 $rows = [ 284 'iw_prefix' => $prefix, 285 'iw_url' => $theurl, 286 'iw_local' => $local, 287 'iw_trans' => $trans 288 ]; 289 290 if ( $prefix === '' || $theurl === '' ) { 291 $status->fatal( 'interwiki-submit-empty' ); 292 break; 293 } 294 295 // Simple URL validation: check that the protocol is one of 296 // the supported protocols for this wiki. 297 // (bug 30600) 298 if ( !wfParseUrl( $theurl ) ) { 299 $status->fatal( 'interwiki-submit-invalidurl' ); 300 break; 301 } 302 303 if ( $do === 'add' ) { 304 $dbw->insert( 'interwiki', $rows, __METHOD__, [ 'IGNORE' ] ); 305 } else { // $do === 'edit' 306 $dbw->update( 'interwiki', $rows, [ 'iw_prefix' => $prefix ], __METHOD__, [ 'IGNORE' ] ); 307 } 308 309 // used here: interwiki_addfailed, interwiki_added, interwiki_edited 310 if ( $dbw->affectedRows() === 0 ) { 311 $status->fatal( "interwiki_{$do}failed", $prefix ); 312 } else { 313 $this->getOutput()->addWikiMsg( "interwiki_{$do}ed", $prefix ); 314 $log = new LogPage( 'interwiki' ); 315 $log->addEntry( 316 'iw_' . $do, 317 $selfTitle, 318 $reason, 319 [ $prefix, $theurl, $trans, $local ], 320 $this->getUser() 321 ); 322 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable 323 $lookup->invalidateCache( $prefix ); 324 } 325 break; 326 } 327 328 return $status; 329 } 330 331 protected function showList() { 332 global $wgInterwikiCentralDB, $wgInterwikiCentralInterlanguageDB, $wgInterwikiViewOnly; 333 334 $canModify = $this->canModify(); 335 336 // Build lists 337 $lookup = MediaWikiServices::getInstance()->getInterwikiLookup(); 338 $iwPrefixes = $lookup->getAllPrefixes( null ); 339 $iwGlobalPrefixes = []; 340 $iwGlobalLanguagePrefixes = []; 341 if ( $wgInterwikiCentralDB !== null && $wgInterwikiCentralDB !== wfWikiID() ) { 342 // Fetch list from global table 343 $dbrCentralDB = wfGetDB( DB_REPLICA, [], $wgInterwikiCentralDB ); 344 $res = $dbrCentralDB->select( 'interwiki', '*', [], __METHOD__ ); 345 $retval = []; 346 foreach ( $res as $row ) { 347 $row = (array)$row; 348 if ( !Language::fetchLanguageName( $row['iw_prefix'] ) ) { 349 $retval[] = $row; 350 } 351 } 352 $iwGlobalPrefixes = $retval; 353 } 354 355 // Almost the same loop as above, but for global inter*language* links, whereas the above is for 356 // global inter*wiki* links 357 $usingGlobalInterlangLinks = ( $wgInterwikiCentralInterlanguageDB !== null ); 358 $isGlobalInterlanguageDB = ( $wgInterwikiCentralInterlanguageDB === wfWikiID() ); 359 $usingGlobalLanguages = $usingGlobalInterlangLinks && !$isGlobalInterlanguageDB; 360 if ( $usingGlobalLanguages ) { 361 // Fetch list from global table 362 $dbrCentralLangDB = wfGetDB( DB_REPLICA, [], $wgInterwikiCentralInterlanguageDB ); 363 $res = $dbrCentralLangDB->select( 'interwiki', '*', [], __METHOD__ ); 364 $retval2 = []; 365 foreach ( $res as $row ) { 366 $row = (array)$row; 367 // Note that the above DB query explicitly *excludes* interlang ones 368 // (which makes sense), whereas here we _only_ care about interlang ones! 369 if ( Language::fetchLanguageName( $row['iw_prefix'] ) ) { 370 $retval2[] = $row; 371 } 372 } 373 $iwGlobalLanguagePrefixes = $retval2; 374 } 375 376 // Split out language links 377 $iwLocalPrefixes = []; 378 $iwLanguagePrefixes = []; 379 foreach ( $iwPrefixes as $iwPrefix ) { 380 if ( Language::fetchLanguageName( $iwPrefix['iw_prefix'] ) ) { 381 $iwLanguagePrefixes[] = $iwPrefix; 382 } else { 383 $iwLocalPrefixes[] = $iwPrefix; 384 } 385 } 386 387 // If using global interlanguage links, just ditch the data coming from the 388 // local table and overwrite it with the global data 389 if ( $usingGlobalInterlangLinks ) { 390 unset( $iwLanguagePrefixes ); 391 $iwLanguagePrefixes = $iwGlobalLanguagePrefixes; 392 } 393 394 // Page intro content 395 $this->getOutput()->addWikiMsg( 'interwiki_intro' ); 396 397 // Add 'view log' link when possible 398 if ( $wgInterwikiViewOnly === false ) { 399 $logLink = $this->getLinkRenderer()->makeLink( 400 SpecialPage::getTitleFor( 'Log', 'interwiki' ), 401 $this->msg( 'interwiki-logtext' )->text() 402 ); 403 $this->getOutput()->addHTML( '<p class="mw-interwiki-log">' . $logLink . '</p>' ); 404 } 405 406 // Add 'add' link 407 if ( $canModify ) { 408 if ( count( $iwGlobalPrefixes ) !== 0 ) { 409 if ( $usingGlobalLanguages ) { 410 $addtext = 'interwiki-addtext-local-nolang'; 411 } else { 412 $addtext = 'interwiki-addtext-local'; 413 } 414 } else { 415 if ( $usingGlobalLanguages ) { 416 $addtext = 'interwiki-addtext-nolang'; 417 } else { 418 $addtext = 'interwiki_addtext'; 419 } 420 } 421 $addtext = $this->msg( $addtext )->text(); 422 $addlink = $this->getLinkRenderer()->makeKnownLink( 423 $this->getPageTitle( 'add' ), $addtext ); 424 $this->getOutput()->addHTML( 425 '<p class="mw-interwiki-addlink">' . $addlink . '</p>' ); 426 } 427 428 $this->getOutput()->addWikiMsg( 'interwiki-legend' ); 429 430 if ( $iwPrefixes === [] && $iwGlobalPrefixes === [] ) { 431 // If the interwiki table(s) are empty, display an error message 432 $this->error( 'interwiki_error' ); 433 return; 434 } 435 436 // Add the global table 437 if ( count( $iwGlobalPrefixes ) !== 0 ) { 438 $this->getOutput()->addHTML( 439 '<h2 id="interwikitable-global">' . 440 $this->msg( 'interwiki-global-links' )->parse() . 441 '</h2>' 442 ); 443 $this->getOutput()->addWikiMsg( 'interwiki-global-description' ); 444 445 // $canModify is false here because this is just a display of remote data 446 $this->makeTable( false, $iwGlobalPrefixes ); 447 } 448 449 // Add the local table 450 if ( count( $iwLocalPrefixes ) !== 0 ) { 451 if ( count( $iwGlobalPrefixes ) !== 0 ) { 452 $this->getOutput()->addHTML( 453 '<h2 id="interwikitable-local">' . 454 $this->msg( 'interwiki-local-links' )->parse() . 455 '</h2>' 456 ); 457 $this->getOutput()->addWikiMsg( 'interwiki-local-description' ); 458 } else { 459 $this->getOutput()->addHTML( 460 '<h2 id="interwikitable-local">' . 461 $this->msg( 'interwiki-links' )->parse() . 462 '</h2>' 463 ); 464 $this->getOutput()->addWikiMsg( 'interwiki-description' ); 465 } 466 $this->makeTable( $canModify, $iwLocalPrefixes ); 467 } 468 469 // Add the language table 470 if ( count( $iwLanguagePrefixes ) !== 0 ) { 471 if ( $usingGlobalLanguages ) { 472 $header = 'interwiki-global-language-links'; 473 $description = 'interwiki-global-language-description'; 474 } else { 475 $header = 'interwiki-language-links'; 476 $description = 'interwiki-language-description'; 477 } 478 479 $this->getOutput()->addHTML( 480 '<h2 id="interwikitable-language">' . 481 $this->msg( $header )->parse() . 482 '</h2>' 483 ); 484 $this->getOutput()->addWikiMsg( $description ); 485 486 // When using global interlanguage links, don't allow them to be modified 487 // except on the source wiki 488 $canModify = ( $usingGlobalLanguages ? false : $canModify ); 489 $this->makeTable( $canModify, $iwLanguagePrefixes ); 490 } 491 } 492 493 protected function makeTable( $canModify, $iwPrefixes ) { 494 // Output the existing Interwiki prefixes table header 495 $out = Html::openElement( 496 'table', 497 [ 'class' => 'mw-interwikitable wikitable sortable body' ] 498 ) . "\n"; 499 $out .= Html::openElement( 'thead' ) . 500 Html::openElement( 'tr', [ 'class' => 'interwikitable-header' ] ) . 501 Html::element( 'th', [], $this->msg( 'interwiki_prefix' )->text() ) . 502 Html::element( 'th', [], $this->msg( 'interwiki_url' )->text() ) . 503 Html::element( 'th', [], $this->msg( 'interwiki_local' )->text() ) . 504 Html::element( 'th', [], $this->msg( 'interwiki_trans' )->text() ) . 505 ( $canModify ? 506 Html::element( 507 'th', 508 [ 'class' => 'unsortable' ], 509 $this->msg( 'interwiki_edit' )->text() 510 ) : 511 '' 512 ); 513 $out .= Html::closeElement( 'tr' ) . 514 Html::closeElement( 'thead' ) . "\n" . 515 Html::openElement( 'tbody' ); 516 517 $selfTitle = $this->getPageTitle(); 518 519 // Output the existing Interwiki prefixes table rows 520 foreach ( $iwPrefixes as $iwPrefix ) { 521 $out .= Html::openElement( 'tr', [ 'class' => 'mw-interwikitable-row' ] ); 522 $out .= Html::element( 'td', [ 'class' => 'mw-interwikitable-prefix' ], 523 $iwPrefix['iw_prefix'] ); 524 $out .= Html::element( 525 'td', 526 [ 'class' => 'mw-interwikitable-url' ], 527 $iwPrefix['iw_url'] 528 ); 529 $attribs = [ 'class' => 'mw-interwikitable-local' ]; 530 // Green background for cells with "yes". 531 if ( isset( $iwPrefix['iw_local'] ) && $iwPrefix['iw_local'] ) { 532 $attribs['class'] .= ' mw-interwikitable-local-yes'; 533 } 534 // The messages interwiki_0 and interwiki_1 are used here. 535 $contents = isset( $iwPrefix['iw_local'] ) ? 536 $this->msg( 'interwiki_' . $iwPrefix['iw_local'] )->text() : 537 '-'; 538 $out .= Html::element( 'td', $attribs, $contents ); 539 $attribs = [ 'class' => 'mw-interwikitable-trans' ]; 540 // Green background for cells with "yes". 541 if ( isset( $iwPrefix['iw_trans'] ) && $iwPrefix['iw_trans'] ) { 542 $attribs['class'] .= ' mw-interwikitable-trans-yes'; 543 } 544 // The messages interwiki_0 and interwiki_1 are used here. 545 $contents = isset( $iwPrefix['iw_trans'] ) ? 546 $this->msg( 'interwiki_' . $iwPrefix['iw_trans'] )->text() : 547 '-'; 548 $out .= Html::element( 'td', $attribs, $contents ); 549 550 // Additional column when the interwiki table can be modified. 551 if ( $canModify ) { 552 $out .= Html::rawElement( 'td', [ 'class' => 'mw-interwikitable-modify' ], 553 $this->getLinkRenderer()->makeKnownLink( 554 $selfTitle, 555 $this->msg( 'edit' )->text(), 556 [], 557 [ 'action' => 'edit', 'prefix' => $iwPrefix['iw_prefix'] ] 558 ) . 559 $this->msg( 'comma-separator' )->escaped() . 560 $this->getLinkRenderer()->makeKnownLink( 561 $selfTitle, 562 $this->msg( 'delete' )->text(), 563 [], 564 [ 'action' => 'delete', 'prefix' => $iwPrefix['iw_prefix'] ] 565 ) 566 ); 567 } 568 $out .= Html::closeElement( 'tr' ) . "\n"; 569 } 570 $out .= Html::closeElement( 'tbody' ) . 571 Html::closeElement( 'table' ); 572 573 $this->getOutput()->addHTML( $out ); 574 $this->getOutput()->addModuleStyles( 'jquery.tablesorter.styles' ); 575 $this->getOutput()->addModules( 'jquery.tablesorter' ); 576 } 577 578 /** 579 * @param string ...$args 580 */ 581 protected function error( ...$args ) { 582 $this->getOutput()->wrapWikiMsg( "<p class='error'>$1</p>", $args ); 583 } 584 585 protected function getGroupName() { 586 return 'wiki'; 587 } 588} 589