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