Jump to content

User:WhitePhosphorus/js/AutoUndoGlobal.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)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* 
 * @author [[User:WhitePhosphorus]]
 * Some English translations came from [[User:Camouflaged Mirage]].
 * See also [[User:WhitePhosphorus/js/AutoUndo.js]] (only work on the wiki you activate the script)
 * 
 * If your wiki does not use { {delete}} as speedy template:
 * Copy the following snippet to your global.js, then replace '<wikidbname>' and '<speedytemplatename>'
 * Check https://noc.wikimedia.org/conf/highlight.php?file=dblists/all.dblist for the <wikidbname> list. eg: English Wikipedia is enwiki, Meta Wiki is metawiki
   	p4js_auto_undo_csd_template = {
		// default: 'delete'
		'commonswiki': 'speedy',  // example, no braces are needed in template name
		'<wikidbname>': '<speedytemplatename>',  // edit this line!
	};
 */
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.util', 'mediawiki.ForeignApi'], function() {
    const translations = {
        portletlink: {
            en: 'Global auto undo',
            zh: '全域回退编辑'
        },
        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>請不要對會跟你打撤銷戰的破壞者用這個腳本。'
        },
        username: {
            en: 'Username: ',
            zh: '用户名:'
        },
        showname: {
            en: 'Show username in rollback summary',
            zh: '在回退摘要中显示用户名'
        },
        shownamedesp: {
            en: ' (Used for grossly inappropriate username)',
            zh: '(适用于极度不恰当的用户名)'
        },
        undolimit: {
            en: 'Maximum times to undo per page: ',
            zh: '每个页面的最多回退次数:'
        },
        unlimited: {
            en: 'Unlimited',
            zh: '无限制'
        },
        limitreached: {
            en: 'Reached the undo limit on this page. Please manually undo if needed',
            zh: '已达到本页面的撤销上限。如有需要请手动回退'
        },
        newpage: {
            en: 'Actions on new page creation: ',
            zh: '新建页面的处理:'
        },
        prependspeedydelete: {
            en: 'Request speedy deletion (Vandalism)',
            zh: '请求快速删除(破坏)'
        },
        prependspeedydeletespam: {
            en: 'Request speedy deletion (Spam)',
            zh: '请求快速删除(广告)'
        },
        speedydelete: {
            en: 'Empty the page and request speedy deletion (Vandalism)',
            zh: '清空页面并请求快速删除(破坏)'
        },
        empty: {
            en: 'Empty the page',
            zh: '清空页面'
        },
        nothing: {
            en: 'Do nothing',
            zh: '不处理'
        },
        start: {
            en: 'Start [Enter]',
            zh: '开始 [Enter]'
        },
        undid: {
            en: 'Successfully undid on page ',
            zh: '已撤銷頁面 '
        },
        cannotundo: {
            en: 'Cannot undo on page ',
            zh: '無法撤銷頁面 '
        },
        cannotedit: {
            en: 'Cannot edit: ',
            zh: '无法完成编辑:'
        },
        revision: {
            en: ', revision id ',
            zh: ',修訂版本 '
        },
        newedit: {
            en: 'New edit: ',
            zh: '新編輯:'
        },
        page: {
            en: ', on page ',
            zh: ',页面 '
        },
        stopped: {
            en: 'Stopped.',
            zh: '已停止。'
        },
        monitoring: {
            en: 'Stalking ',
            zh: '正在監視 '
        },
        glocked: {
            en: 'is global (b)locked, stop stalking.',
            zh: '已被全域锁定/封禁,停止监视。'
        },
        unknownerror: {
            en: 'Unknown error, please see the browser console.',
            zh: '未知错误,请看浏览器控制台。'
        },
        tokenerror: {
            en: 'Cannot obtain the csrf token.',
            zh: '无法获取编辑令牌。'
        },
        networktokenerror: {
            en: 'Loss of connectivity, cannot obtain the csrf token.',
            zh: '网络异常,无法获取编辑令牌。'
        },
        pagecreationskipped: {
            en: 'Page creation skipped.',
            zh: '已忽略页面建立。'
        },
        colon: {
            en: ': ',
            zh: ':'
        },
    };

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

	if (typeof(p4js_auto_undo_csd_template) !== 'object') {
		p4js_auto_undo_csd_template = {
			// default: 'delete'
			'commonswiki': 'speedy',
		};
	}

    const SUMMARY = 'revert edits by $1';
    const CONTRIB = '[[Special:Contributions/$1|$1]]';
    const HIDDEN = '<username hidden>';

    let summary = null;
    let uid = null;
    let hidename = false;
    let handle_creation = 'csd';
    let started = false;
    let apis = {};
    let urls = {};
    let tokens = {};
    let undolimit = 0;  // undo limit per page
    let undidpages = {};

    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('undid')}${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('cannotundo')}${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' &&
                change.log_params && change.log_params.added && change.log_params.added.includes('locked');
        }
    };

    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 (undolimit > 0 && undidpages[fulltitle] >= undolimit) {
                printFail(indexurl, change, i18n('limitreached'));
                return;
            } else {
                undidpages[fulltitle] = undidpages[fulltitle] ? undidpages[fulltitle]+1 : 1;
            }

            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 undo = function(token, target) {
                api.post({
                    action: 'edit',
                    token: token,
                    title: change.title,
                    summary: SUMMARY.replace(/\$1/g, hidename ? HIDDEN : CONTRIB.replace(/\$1/g, target)),
                    undo: change.revision.new,
                    minor: true,
                    bot: true
                }).done(function(data) {
                    if (data && data.edit && data.edit.result && data.edit.result === 'Success') {
                        printSuccess(indexurl, change);
                    } else {
                        if (data.error) {
                            if (data.error && data.error.code === 'nosuchrevid') {
                                printFail(indexurl, change, data.error.code);
                                console.log(data.error.info);
                                return;
                            }
                        }
                        printFail(indexurl, change, i18n('unknownerror'));
                        console.log(data);
                    }
                }).fail(function(errcode) {
                    if (errcode === 'nosuchrevid') {
                        let newtext = '';
                        if (handle_creation !== 'nothing') {
                            if (handle_creation === 'rcsd' || handle_creation === 'csd') {
                                newtext = `{{${p4js_auto_undo_csd_template[change.wiki] || 'delete'}|Vandalism}}\n`;
                            } else if (handle_creation === 'csdspam') {
                                newtext = `{{${p4js_auto_undo_csd_template[change.wiki] || 'delete'}|Spam}}\n`;
                            }
                            let body = {
                                action: 'edit',
                                token: token,
                                title: change.title,
                                summary: SUMMARY.replace(/\$1/g, hidename ? HIDDEN : CONTRIB.replace(/\$1/g, target)),
                                minor: true,
                                bot: 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
                                    printSuccess(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 {
                        printFail(indexurl, change, i18n('cannotedit') + errcode);
                    }
                });   
            }
    
            // try to query the token on that foreign wiki
            if (!tokens[change.wiki]) {
                api.get({
                    action: 'query',
                    meta: 'tokens'
                }).done(function(data) {
                    if (!(data && data.query && data.query.tokens && data.query.tokens.csrftoken)) {
                        console.log(`Failed to query edit token on wiki ${change.wiki}: see following output`);
                        console.log(data);
                        printFail(indexurl, change, i18n('tokenerror'));
                        return;
                    }
                    tokens[change.wiki] = data.query.tokens.csrftoken;
                    undo(tokens[change.wiki], change.user);
                }).fail(function() {
                    printFail(indexurl, change, i18n('networktokenerror'));
                });
            } else {
                undo(tokens[change.wiki], change.user);
            }
        };
    };

    $(mw.util.addPortletLink('p-cactions', '#', i18n('portletlink'))).click(function (e) {
        $('#content').html(`<div id="p4js-globalundo-settings"><p>${i18n('introduction')}</p>
            <label>${i18n('username')}<input type="text" id="p4js-globalundo-username"></label><br>
            <label><input type="checkbox" checked id="p4js-globalundo-showusername">${i18n('showname')}</label>${i18n('shownamedesp')}<br>
            <label>${i18n('undolimit')}<input type="text" id="p4js-globalundo-undolimit" value="3"></label>
            <label><input type="checkbox" id="p4js-globalundo-unlimited">${i18n('unlimited')}</label><br>
            <label>${i18n('newpage')}<select id="p4js-globalundo-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-globalundo-start">${i18n('start')}</a></div>`);

        $('#p4js-globalundo-undolimit')[0].disabled = $('#p4js-globalundo-unlimited')[0].checked;
        $('#p4js-globalundo-unlimited').click(function(e) {
            $('#p4js-globalundo-undolimit')[0].disabled = this.checked;
        });

        $('#p4js-globalundo-start').click(function(e) {
            uid = $('#p4js-globalundo-username')[0].value.trim();
            if (uid) {
                // normalize the username: replace underscore with space
                uid = uid.replace(/_/g, ' ');
                // make the first letter uppercase
                uid = uid.charAt(0).toUpperCase() + uid.slice(1);
                hidename = !$('#p4js-globalundo-showusername')[0].checked;
                handle_creation = $('#p4js-globalundo-creation')[0].value;
                if ($('#p4js-globalundo-unlimited')[0].checked) {
                    undolimit = 0;
                } else {
                    undolimit = $('#p4js-globalundo-undolimit')[0].value;
                    if (undolimit < 1 || isNaN(undolimit)) {
                        undolimit = 3;
                    }
                }
                $('#p4js-globalundo-settings').hide();
                start();
            }
        });

        $('#p4js-globalundo-username').focus();
        $(window).on('keypress', function(e) {
            // press enter to start
            if (e.which == 13) {
                $('#p4js-globalundo-start').click();
            }
        });
    });
});
});