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