User:DannyS712/AutoRollbackGlobal.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.
/* See also [[User:WhitePhosphorus/js/AutoUndoGlobal.js]] */
// <nowiki>
mw.loader.load('//meta.wikimedia.org/w/index.php?title=User:WhitePhosphorus/js/eventsource-polyfill.js&action=raw&ctype=text/javascript');
mw.loader.load('//meta.wikimedia.org/w/index.php?title=User:WhitePhosphorus/js/ipaddr.js&action=raw&ctype=text/javascript');

$(function () {
mw.loader.using(['mediawiki.ForeignApi'], function() {
	const translations = {
		portletlink: {
			en: 'Global auto rollback',
			vi: 'Tự động lùi sửa toàn cục'
		},
		introduction: {
			en: 'IP ranges are fully supported. You can also use /regex/ (two backslashes are needed to escape characters). Only the new edits will be reverted after you click [start], please manually revert previous edits if needed.<br>Please avoid using the script on vandals who may undo-war with you.',
			zh: '用户支持 IP 段,支持 /正则表达式/(需要两个反斜杠来转义)。確定之後將會跟蹤此用戶的全域編輯,並將其全部回退。(之前的編輯請手動回退)<br>請不要對會跟你打撤銷戰的破壞者用這個腳本。',
			vi: 'Dải IP cũng được hỗ trợ. Bạn cũng có thể sử dụng /regex/ (cần có hai dấu gạch chéo để thoát các ký tự). Chỉ các sửa đổi mới sẽ được lùi lại sau khi bạn nhấp vào [Bắt đầu], vui lòng lùi lại các sửa đổi trước đó theo cách thủ công nếu cần.<br>Vui lòng tránh sử dụng tập lệnh cho việc phá hoại hoặc bút chiến. '
		},
		username: {
			en: 'Username: ',
			zh: '用户名:',
			vi: 'Tên người dùng'
		},
		showname: {
			en: 'Show username in rollback summary',
			vi: 'Hiển thị tên người dùng trong tóm lược sửa đổi',
		},
		shownamedesp: {
			en: ' (Used for grossly inappropriate username)',
			zh: '(适用于极度不恰当的用户名)',
			vi: ' (Được sử dụng cho tên người dùng không được chấp nhận)'
		},
		newpage: {
			en: 'Actions on new page creation: ',
			zh: '新建页面的处理:',
			vi: 'Tác vụ trên trang mới: '
		},
		prependspeedydelete: {
			en: 'Prepend {{delete|Vandalism}}',
			zh: '添加{{delete|Vandalism}}',
			vi: 'Yêu cầu {{delete|Vandalism}}'
		},
		prependspeedydeletespam: {
			en: 'Prepend {{delete|Spam}}',
			zh: '添加{{delete|Spam}}',
			vi: 'Yêu cầu {{delete|Spam}}'
		},
		speedydelete: {
			en: 'Replace the content with {{delete|vandalism}}',
			zh: '使用{{delete|vandalism}}替换页面',
			vi: 'Thay thế nội dung bằng {{delete|vandalism}}'
		},
		empty: {
			en: 'Empty the page',
			zh: '清空页面',
			vi: 'Tẩy trống trang'
		},
		nothing: {
			en: 'Do nothing',
			zh: '不处理',
			vi: 'Không làm gì cả'
		},
		start: {
			en: 'Start [Enter]',
			zh: '开始 [Enter]',
			vi: 'Bắt đầu [Enter]'
		},
		rolledback: {
			en: 'Successfully rolledback on page ',
			vi: 'Đã lùi lại sửa đổi trên trang '
		},
		cannotrollback: {
			en: 'Cannot rollback on page ',
			vi: 'Không thể lùi lại sửa đổi trên trang '
		},
		revision: {
			en: ', revision id ',
			zh: ',修訂版本 ',
			vi: ', ID sửa đổi '
		},
		newedit: {
			en: 'New edit: ',
			zh: '新編輯:',
			vi: 'Sửa đổi mới: ',
		},
		page: {
			en: ', on page ',
			zh: ',页面 ',
			vi: ', trên trang '
		},
		stopped: {
			en: 'Stopped.',
			zh: '已停止。',
			vi: 'Đã hủy bỏ.'
		},
		monitoring: {
			en: 'Stalking ',
			zh: '正在監視 ',
			vi: 'Đang giám sát '
		},
		glocked: {
			en: 'is global (b)locked, stop stalking.',
			zh: '已被全域锁定/封禁,停止监视。',
			vi: 'đã bị cấm/khóa toàn cục, đang dừng giám sát.'
		},
		unknownerror: {
			en: 'Unknown error, please see the browser console.',
			zh: '未知错误,请看浏览器控制台。',
			vi: 'Lỗi không xác định, vui lòng kiểm tra bảng điều khiển của trình duyệt.'
		},
		tokenerror: {
			en: 'Cannot obtain the tokens.',
			vi: 'Không thể lấy token.'
		},
		networktokenerror: {
			en: 'Loss of connectivity, cannot obtain the tokens.',
			vi: 'Không thể lấy token vì đã mất kết nối với Internet.'
		},
		pagecreationskipped: {
			en: 'Page creation skipped.',
			zh: '已忽略页面建立。',
			vi: 'Đã bỏ qua trang mới.'
		},
		colon: {
			en: ': ',
			zh: ':',
			vi: ': '
		},
		deletiontag: {
			en: 'tag for speedy deletion',
			vi: 'gắn thẻ xóa nhanh'
		},
		creationhandled: {
			en: 'Page creation handled for page ',
			vi: 'Đã xử lý trang mới '
		},
		markbotedits: {
			en: 'Mark rollbacks as bot edits',
			vi: 'Đánh dấu tác vụ lùi sửa là sửa đổi của bot'
		}
	};

	const i18n = function(key) {
		var userlang = mw.config.get('wgUserLanguage');
		var lang = userlang;
		// we have zh, zh-hans, zh-cn, zh-tw, vi, etc.
		if (userlang.startsWith('zh')) {
			lang = 'zh';
		}
		else if (userlang.startsWith('vi')) {
			lang = 'vi'; // Vietnamese translation by [[User:Tryvix1509]]
		}
		// fallback to en
		return translations[key][lang] || translations[key]['en'];
	};

	let summary = null;
	let uid = null;
	let hidename = false;
	let handle_creation = 'csd';
	let started = false;
	let apis = {};
	let urls = {};
	let tokens = {};
	let customSummaryOrFalse = false;
	let markBotEdits = true;

	let eventSource = null;

	const pageLink = function(wikiurl, page, title) {
		return `<a target="_blank" href="${wikiurl}?title=${page}">${(title || page)}</a>`;
	};

	const print = function(html) {
		$('#content').append($('<p>').html(html));
	};

	const printSuccess = function(url, change) {
		print(`<span style="color:green">${i18n('rolledback')}${pageLink(url, change.title)}@${change.wiki}${i18n('revision')}${pageLink(url, 'Special:diff/'+change.revision.new, change.revision.new)}`);
	};

	const printCreation = function(url, change) {
		print(`<span style="color:green">${i18n('creationhandled')}${pageLink(url, change.title)}@${change.wiki}${i18n('revision')}${pageLink(url, 'Special:diff/'+change.revision.new, change.revision.new)}`);
	};

	const printFail = function(url, change, reason) {
		print(`<span style="color:red">${i18n('cannotrollback')}${pageLink(url, change.title)}@${change.wiki}${i18n('revision')}${pageLink(url, 'Special:diff/'+change.revision.new, change.revision.new)}${i18n('colon')}${reason}`);
	};

	const isMatch = function(user, pattern) {
		let slashIndex = pattern.indexOf('/', 1);
		if (pattern.startsWith('/') && slashIndex !== -1) {
			// check regex
			try {
				let body = pattern.slice(1, slashIndex);
				let flags = pattern.slice(slashIndex+1);
				let regex = new RegExp(body, flags);
				return regex.test(user);
			} catch (e) {}
		}
		if (ipaddr.isValid(user)) {
			try {
				// check accurately ip match
				// this is intended for IPv6 addresses as they may contain upper & lower cases
				// will throw an exception for ip ranges
				return ipaddr.parse(user).toNormalizedString() === ipaddr.parse(pattern).toNormalizedString();
			} catch (e) {}
			try {
				// check ip range
				let range = ipaddr.parseCIDR(pattern);
				return ipaddr.parse(user).match(range);
			} catch (e) {}
		}
		return user === pattern;
	};

	const isGloballyBlocked = function(change, user) {
		if (change.wiki !== 'metawiki') return false;
		let range = null;
		try {
			range = ipaddr.parseCIDR(user);
		} catch (e) {}
		if (ipaddr.isValid(user) || range !== null) {
			// ip or ip range => check gblock
			if (change.title !== `User:${user}`) return false;
			return change.log_type === 'gblblock' && change.log_action === 'gblock2';
		} else {
			// registered => check glock
			if (change.title !== `User:${user}@global`) return false;
			return change.log_type === 'globalauth' && change.log_action === 'setstatus' &&
				typeof change.log_params === 'object' &&
				change.log_params[0] === 'locked' && change.log_params[1] === '(none)';
		}
	};

	const start = function() {
		$('#content').html(i18n('monitoring') + uid);
		// load site matrix
		$.ajax({
			url: mw.util.wikiScript('api'),
			data: {
				format: 'json',
				action: 'sitematrix',
			},
			dataType: 'json',
			type: 'POST',
		}).then(function(data) {
			if (!data.sitematrix) return;
			// meta, commons, etc.
			for (let wiki of data.sitematrix.specials) {
				urls[wiki.dbname] = wiki.url;
			}
			for (let language of Object.values(data.sitematrix)) {
				if (!language.site) continue;
				for (let wiki of language.site) {
					urls[wiki.dbname] = wiki.url;
				}
			}
		});
		started = true;

		eventSource = new window.EventSource('https://stream.wikimedia.org/v2/stream/recentchange');
	
		eventSource.onopen = function(event) {
			console.log('--- Opened connection.');
		};
	
		eventSource.onerror = function(event) {
			console.error('--- Encountered error', event);
		};
	
		eventSource.onmessage = function(event) {
			if (!started) return;
			let change = JSON.parse(event.data);
			if (isGloballyBlocked(change, uid)) {
				print(`${uid} ${i18n('glocked')}`);
				this.close();
				return;
			}
			if (!isMatch(change.user, uid)) return;
	
			if (!urls[change.wiki]) {
				console.log(`Unknown wiki ${change.wiki}`);
				return;
			}

			let apiurl = urls[change.wiki] + mw.util.wikiScript('api');
			let indexurl = urls[change.wiki] + mw.config.get('wgScript');
			print(`${i18n('newedit')}${change.user}${i18n('revision')}${change.revision.new}${i18n('page')}${pageLink(indexurl, change.title)}@${change.wiki}`);
	
			let fulltitle = change.title+'@'+change.wiki;

			if (!apis[change.wiki]) {
				apis[change.wiki] = (change.wiki === mw.config.get('wgDBname') ? new mw.Api() : new mw.ForeignApi(apiurl));
			}
			let api = apis[change.wiki];
	
			// will be called after we get a token
			const rollback = function(tokens, target) {
				api.post({
					action: 'rollback',
					token: tokens.rollback,
					title: change.title,
					user: change.user,
					summary: customSummaryOrFalse, // mw.api removes false and uses the default rollback summary
					markbot: markBotEdits
				}).done(function(data) {
					if (data && data.rollback && data.rollback.revid) {
						printSuccess(indexurl, change);
					} else {
						printFail(indexurl, change, i18n('unknownerror'));
						console.log(data);
					}
				}).fail(function(errcode) {
					if (errcode === 'onlyauthor') {
						let newtext = '';
						if (handle_creation !== 'nothing') {
							if (handle_creation === 'rcsd' || handle_creation === 'csd') {
								newtext = '{{delete|Vandalism}}\n';
							} else if (handle_creation === 'csdspam') {
								newtext = '{{delete|Spam}}\n';
							}
							let body = {
								action: 'edit',
								token: tokens.csrf,
								title: change.title,
								summary: i18n('deletiontag'),
								minor: true
							};
							if (handle_creation === 'csd' || handle_creation === 'csdspam') {
								body.prependtext = newtext;
							} else {
								body.text = newtext;
							}
							api.post(body).done(function(data2) {
								if (data2 && data2.edit && data2.edit.result && data2.edit.result === 'Success') {
									// TODO: log that it's a creation
									printCreation(indexurl, change);
								} else {
									if (data2.error) {
										printFail(indexurl, change, data2.error.code);
										console.log(data2.error.info);
										return;
									}
									printFail(indexurl, change, i18n('unknownerror'));
									console.log(data2);
								}
							});
						} else {							
							printFail(indexurl, change, i18n('pagecreationskipped'));
						}
					} else if (errcode === 'alreadyrolled') {
						// Treat someone else already rolling it back as a success
						printSuccess(indexurl, change);
					} else {
						printFail(indexurl, change, i18n('cannotrollback') + errcode);
					}
				});
			};
	
			// try to query the token on that foreign wiki
			if (!tokens[change.wiki]) {
				api.get({
					action: 'query',
					meta: 'tokens',
					type: 'csrf|rollback'
				}).done(function(data) {
					if (!(data && data.query && data.query.tokens && data.query.tokens.csrftoken && data.query.tokens.rollbacktoken)) {
						console.log(`Failed to query tokens on wiki ${change.wiki}: see following output`);
						console.log(data);
						printFail(indexurl, change, i18n('tokenerror'));
						return;
					}
					tokens[change.wiki] = {
						csrf: data.query.tokens.csrftoken,
						rollback: data.query.tokens.rollbacktoken
					};
					rollback(tokens[change.wiki], change.user);
				}).fail(function() {
					printFail(indexurl, change, i18n('networktokenerror'));
				});
			} else {
				rollback(tokens[change.wiki], change.user);
			}
		};
	};

	if (mw.config.get('wgNamespaceNumber') !== -1) {
		return;
	}
	let specialPage = mw.config.get('wgCanonicalSpecialPageName');
	if (specialPage=== 'Blankpage') {
		let title = mw.config.get('wgTitle').split('/');
		if (title && title[1] && title[1] === 'AutoRollbackGlobal') {
			window.addEventListener(
				'beforeunload',
				function ( event ) {
					event.returnValue = 'Are you sure you want to close this tab?';
				},
				false
			);
			
			$('#content').html(`<div id="p4js-globalrollback-settings"><p>${i18n('introduction')}</p>
				<label>${i18n('username')}<input type="text" id="p4js-globalrollback-username"></label><br>
				<label><input type="checkbox" checked id="p4js-globalrollback-showusername">${i18n('showname')}</label>${i18n('shownamedesp')}<br>
				<label><input type="checkbox" checked id="p4js-globalrollback-markbotedits">${i18n('markbotedits')}</label><br>
				<label>${i18n('newpage')}<select id="p4js-globalrollback-creation">
				<option value="csd">${i18n('prependspeedydelete')}</option>
				<option value="csdspam">${i18n('prependspeedydeletespam')}</option>
				<option value="rcsd">${i18n('speedydelete')}</option>
				<option value="empty">${i18n('empty')}</option>
				<option value="nothing">${i18n('nothing')}</option></select></label><br>
				<a id="p4js-globalrollback-start">${i18n('start')}</a></div>`);
				
			$('#p4js-globalrollback-username').val( mw.util.getParamValue( 'targetUser' ) );

			$('#p4js-globalrollback-start').click(function(e) {
				uid = $('#p4js-globalrollback-username')[0].value;
				if (uid) {
					hidename = !$('#p4js-globalrollback-showusername')[0].checked;
					handle_creation = $('#p4js-globalrollback-creation')[0].value;
					markBotEdits = $('#p4js-globalrollback-markbotedits')[0].checked;
					$('#p4js-globalrollback-settings').hide();
					if ( hidename ) {
						customSummaryOrFalse = "Reverted edits by the previous user";
					} else {
						customSummaryOrFalse = false;
					}
					start();
				}
			});

			$('#p4js-globalrollback-username').focus();
			$(window).on('keypress', function(e) {
				// press enter to start
				if (e.which == 13) {
					$('#p4js-globalrollback-start').click();
				}
			});
		}
	} else if (specialPage === 'Contributions') {
		let links = $('#contentSub > .mw-contributions-user-tools > .mw-changeslist-links');
		let targetUser = mw.config.get( 'wgRelevantUserName' );
		links.html( links.html() + '<span><a href="/wiki/Special:BlankPage/AutoRollbackGlobal?targetUser=' + targetUser + '">auto rollback global</a></span>');
	}
});
});
// </nowiki>