MediaWiki:Gadget-globalmassblock.js

From Meta, a Wikimedia project coordination wiki

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
/**
 * Adds a client-side Special:MassGlobalBlock (404!) until a server-side solution is implemented.
 * See https://phabricator.wikimedia.org/T124607
 * Usage: Enable the gadget at [[Special:Gadgets]] and go to Special:MassGlobalBlock.
 */

var SpecialMassGlobalBlock = {
	name: 'Mass global block',
	api: null,
	$content: $( '#mw-content-text' ),
	expiryTimes: [ '1 day', '3 days', '1 week', '2 weeks', '1 month', '6 months', '1 year', '2 years', '3 years' ],
	blockReasons: [ 'Cross-wiki spam: spambot', '[[m:Special:MyLanguage/NOP|Open proxy]]: See the [[m:WM:OP/H|help page]] if you are affected', '[[m:Special:MyLanguage/NOP|Open proxy/Webhost]]: See the [[m:WM:OP/H|help page]] if you are affected <!-- INSERT PROVIDER -->' ],
	MAX_LIMIT: 20000,

	execute: function() {
		SpecialMassGlobalBlock.api = new mw.Api();
		document.title = this.name + ' - ' + mw.config.get( 'wgSiteName' );
		$( '#firstHeading' ).text( this.name );
		this.$content.empty();
		this.$content.append(
			'<p> This page allows doing mass global blocks on lots of IP addresses or ranges at once. You can block '
				+ 'a maximum of ' + this.MAX_LIMIT + ' targets in one submission. Please use this tool with care.<br />'
				+ '<em>Try not to flood StewardBot out of the IRC channel!</em> </p>'
		);
		this.$content.append( this.getFormPanel().$element );
		this.submit.on( 'click', this.onSubmit );
	},

	initFormWidgets: function() {
		// Validation callback for text inputs
		var isEmpty = function( val ) {
			if ( val.trim() === '' ) {
				return false;
			}
			return true;
		};

		this.targets = new OO.ui.MultilineTextInputWidget( {
			id: 'mw-mgblock-targets',
			multiline: true,
			required: true,
			rows: 20,
			maxRows: 20000,
			autocomplete: false,
			placeholder: 'List of IP addresses or ranges separated by newline',
			validate: isEmpty
		} );

		this.expiry = new OO.ui.ComboBoxInputWidget( {
			id: 'mw-mgblock-expiry',
			required: true,
			options: this.expiryTimes.map( function( expiry ) {
				return { data: expiry };
			} ),
			validate: isEmpty
		} );

		this.reason = new OO.ui.ComboBoxInputWidget( {
			id: 'mw-mgblock-reason',
			required: true,
			options: this.blockReasons.map( function( reason ) {
				return { data: reason };
			} ),
			validate: isEmpty
		} );

		this.checkboxes = new OO.ui.CheckboxMultiselectInputWidget( {
			id: 'mw-mgblock-checkboxes',
			options: [ {
				data: 'anononly',
				label: 'Block anonymous users only'
			}, {
				data: 'alsolocal',
				label: 'Also block the given IP address locally on this wiki'
			}, {
				data: 'localblockstalk',
				label: 'Block user from editing their own talk page locally'
			}, {
				data: 'modify',
				label: 'Modify any existing blocks'
			} ]
		} );

		this.submit = new OO.ui.ButtonInputWidget( {
			id: 'mw-mgblock-submit',
			label: 'Submit',
			flags: [ 'primary', 'destructive' ]
		} );
	},

	getFormFields: function() {
		return [ {
			widget: this.targets,
			config: {
				label: 'List of IP addresses and ranges to block',
				align: 'top'
			}
		}, {
			widget: this.expiry,
			config: {
				label: 'Expiry',
				align: 'top',
			}
		}, {
			widget: this.reason,
			config: {
				label: 'Reason',
				align: 'top',
			}
		}, {
			widget: this.checkboxes,
			config: {
				align: 'inline'
			}
		} ];
	},

	getFormPanel: function() {
		this.initFormWidgets();
		var formFields = this.getFormFields()
			.map( function( field ) {
				return new OO.ui.FieldLayout( field.widget, field.config );
			} );
		var fieldset = new OO.ui.FieldsetLayout( {
			items: formFields.concat( new OO.ui.FieldLayout( this.submit ) ),
		} );
		return new OO.ui.PanelLayout( {
			id: 'mw-mgblock-form',
			expanded: false,
			$content: fieldset.$element
		} );
	},

	disableForm: function( state ) {
		this.submit.setDisabled( state );
		this.targets.setReadOnly( state );
		this.expiry.setReadOnly( state );
		this.reason.setReadOnly( state );
		this.checkboxes.setDisabled( state );
	},

	/**
	 * Click handler for the submit button. Does input validation, controls form state,
	 * and show errors to the user whenever necesarry. If everything seem fine, attempt the API requests.
	 */
	onSubmit: function() {
		var self = SpecialMassGlobalBlock,
			textWidgets = [ self.targets, self.expiry, self.reason ],
			enableForm = function() {
				self.disableForm( false );
			},
			showError = function( errorMsg ) {
				OO.ui.alert( errorMsg ).done( enableForm );
			};

		self.disableForm( true );

		// Check whether all text input fields are not blank
		var blank = false;
		$.each( textWidgets, function( i, widget ) {
			if ( widget.getValue().trim() === '' ) {
				blank = true;
				return false;
			}
		} );
		if ( blank ) {
			showError( 'All fields are required. Please enter valid input.' );
			return;
		}

		// Split the target field input by new lines and:
		//  - strip whitespace
		//  - add to targets array if not already present (to avoid dupes)
		var targets = [],
			targetLines = self.targets.getValue().split( '\n' );
		for ( var i = 0; i < targetLines.length; i++ ) {
			var line = targetLines[ i ].trim();
			if ( line !== '' && $.inArray( line, targets ) === -1 ) {
				targets.push( line );
			}
		}

		var targetsCount = targets.length;
		for ( i = 0; i < targetsCount; i++ ) {
			if ( mw.util.isIPAddress( targets[ i ], true ) === false ) {
				showError( 'Invalid IP address or IP range: ' + targets[ i ] );
				return;
			}
		}

		if ( targetsCount > self.MAX_LIMIT ) {
			showError( 'Maximum number of target IPs exceeded. You entered ' + targetsCount + ' IPs.' );
			return;
		}

		var blockSettings = {
			action: 'globalblock',
			expiry: self.expiry.getValue(),
			reason: self.reason.getValue()
		};
		self.checkboxes.getValue().forEach( function( value ) {
			blockSettings[ value ] = true;
		} );

		var progressBar = new OO.ui.ProgressBarWidget( {
			progress: 0
		} );
		var progressField = new OO.ui.FieldLayout(
			progressBar,
			{ label: 'Progress:' }
		);
		self.$content.append( progressField.$element );

		// Initialize and start sending API requests. The requests are sent one after another.
		// If the API throws an error, this will stop sending future requests and will
		// tell the user about it.
		var iterator = new self.Iterator( targets, {
			onIteration: function( me, ip, curIndex, count ) {
				self.doApiRequest( Object.assign( blockSettings, { target: ip } ) )
					.done( function() {
						progressBar.setProgress( Math.round( ( curIndex / count ) * 100 ) );
						setTimeout( me.next, 10 );
					} )
					.fail( function( errorMsg ) {
						me.error( errorMsg );
					} );
			},
			onError: function( me, current, errMsg ) {
				progressField.$element.remove();
				showError( 'Error occured in API request while attempting to block ' + current
					+ '. Please check whether your input is valid. Script has been terminated.'
				);
				enableForm();
			},
			onComplete: function( me, last, count ) {
				progressBar.setProgress( 100 );
				OO.ui.alert( 'Finished. Successfully blocked ' + count + ' IPs.' );
			}
		} );
		iterator.start();

	},

	doApiRequest: function( params ) {
		return this.api.postWithToken( 'csrf', params )
			.then( function() {
				return true;
			}, function( data ) {
				return data;
			} );
	},

	/**
	 * Based on mw.siteMatrix.Iterator() at [[mw:User:Krinkle/Snippets/Iterate_SiteMatrix_in_JavaScript]]
	 */
	Iterator: function( array, funcs ) {
		var self = this,
			arrLength = array.length,
			i, current;

		self.next = function() {
			if ( i < arrLength ) {
				current = array[ i ];
				funcs.onIteration( self, current, i, arrLength );
				i++;
			} else {
				funcs.onComplete( self, current, arrLength );
			}
		};
		self.error = function( errMsg ) {
			console.log( current, errMsg );
			funcs.onError( self, current, errMsg );
		};
		self.start = function() {
			i = 0;
			self.next();
		};
		return self;
	}
};

if ( mw.config.get( 'wgNamespaceNumber' ) === -1 && mw.config.get( 'wgTitle' ) === 'MassGlobalBlock' && mw.config.get( 'wgGlobalGroups' ).indexOf( 'steward' ) > -1 ) {
	// Load dependencies conditionally as we just want those on one page only
	mw.loader.using( [ 'oojs-ui', 'mediawiki.util', 'mediawiki.api' ], function() {
		SpecialMassGlobalBlock.execute();
	} );
} else if ( mw.config.get( 'wgCanonicalSpecialPageName' ) === 'GlobalBlock' ) {
	var $a = $( '<a>' )
		.attr( 'href', mw.config.get( 'wgServer' ) + '/wiki/Special:MassGlobalBlock' )
		.text( 'Mass global block' );
	$( '#contentSub > #mw-content-subtitle > a:last-child' ).after( ' | ', $a );
}