Jump to content

User:Daniel Quinlan/Scripts/RangeHelper.js

From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Daniel Quinlan (talk | contribs) at 00:45, 24 April 2025 (query block log asynchronously, improve block entry formatting). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
mw.loader.using(['mediawiki.api', 'mediawiki.util', 'mediawiki.DateFormatter']).then(function() {
	// state variables
	const wikitextCache = new Map();
	let api = null;
	let formatTimeAndDate = null;

	// activate on relevant special pages
	if (mw.config.get('wgCanonicalSpecialPageName') === 'Contributions') {
		const userToolsBDI = document.querySelector('.mw-contributions-user-tools bdi');
		const userName = userToolsBDI ? userToolsBDI.textContent.trim() : '';
		const ip = extractIP(userName);
		if (ip) {
			addContributionsLinks(ip);
		}
	} else if (mw.config.get('wgCanonicalSpecialPageName') === 'Blankpage') {
		const pageName = mw.config.get('wgPageName') || '';
		const match = pageName.match(/^Special:BlankPage\/RangeBlocks\/(.+)$/);
		if (match) {
			const ip = extractIP(match[1]);
			if (ip) {
				addRangeBlocks(ip);
			}
		}
	} else if (mw.config.get('wgPageName') === 'Special:Log/block') {
		const pageParam = mw.util.getParamValue('page');
		if (pageParam) {
			const match = pageParam.match(/^User:(.+)$/);
			if (match) {
				const ip = extractIP(match[1]);
				if (ip) {
					mw.util.addPortletLink('p-tb', `/wiki/Special:BlankPage/RangeBlocks/${ip.ip}`, "Find range blocks");
				}
			}
		}
	}
	return;

	// adds links to user tools
	function addContributionsLinks(ip) {
		const userToolsContainer = document.querySelector('.mw-contributions-user-tools .mw-changeslist-links');
		if (!userToolsContainer) return;
		const spans = userToolsContainer.querySelectorAll('span');
		let insertBefore = null;
		for (const span of spans) {
			if (span.textContent.toLowerCase().includes('global')) {
				insertBefore = span;
				break;
			}
		}
		if (!insertBefore) return;
		let floor = 16, ceiling = 24, steps = 8;
		if (ip.version === 6) {
			floor = 32;
			ceiling = 64;
			if (ip.mask >= 64)
				steps = 16;
		}
		let links = [];
		const rangeBlockLink = document.createElement('a');
		rangeBlockLink.href = `/wiki/Special:BlankPage/RangeBlocks/${ip.ip}`;
		rangeBlockLink.textContent = 'range block log';
		rangeBlockLink.className = 'mw-link-range-blocks';
		links.push(rangeBlockLink);
		for (let mask = floor; mask <= ceiling && mask < ip.mask; mask += steps) {
			const contribsLink = document.createElement('a');
			contribsLink.href = `/wiki/Special:Contributions/${ip.ip}/${mask}`;
			contribsLink.textContent = `/${mask}`;
			contribsLink.className = 'mw-contributions-link-range-suggestion';
			links.push(contribsLink)
		}
		links.forEach(link => {
			const span = document.createElement('span');
			span.appendChild(link);
			userToolsContainer.insertBefore(span, insertBefore);
		});
	}

	// find range blocks
	async function addRangeBlocks(ip) {
		document.title = `Range blocks for ${ip.ip}`;
		const heading = document.querySelector('#firstHeading');
		if (heading) {
			heading.innerHTML = `Range blocks for <a href="/wiki/Special:Contributions/${ip.ip}">${ip.ip}</a>`;
		}
		const contentContainer = document.querySelector('#mw-content-text');
		if (!contentContainer) return;
		contentContainer.innerHTML = '';
		let masks;
		if (ip.version === 6) {
			masks = Array.from({ length: 46 }, (_, i) => 64 - i);
		} else {
			masks = Array.from({ length: 16 }, (_, i) => 31 - i);
		}
		api = new mw.Api();
		const statusMessage = document.createElement('p');
		statusMessage.textContent = 'Querying logs for relevant IP range blocks...';
		contentContainer.appendChild(statusMessage);
		const resultsList = document.createElement('ul');
		contentContainer.appendChild(resultsList);
		formatTimeAndDate = mw.loader.require('mediawiki.DateFormatter').formatTimeAndDate;
		const blocks = [];
		const blockPromises = masks.map(mask => {
			const range = ip.version === 6 ? maskedIPv6(ip.ip, mask) : maskedIPv4(ip.ip, mask);
			return getBlockLogs(api, range).then(async (blockLogs) => {
				for (const block of blockLogs) {
					const formattedBlock = await formatBlockEntry(block);
					blocks.push({ logid: block.logid, formattedBlock });
				}
			}).catch(error => {
				console.error(`Error fetching block logs for range ${range}:`, error);
			});
		});
		await Promise.all(blockPromises);
		blocks.sort((a, b) => b.logid - a.logid);
		blocks.forEach(({ formattedBlock }) => {
			const li = document.createElement('li');
			li.innerHTML = formattedBlock;
			resultsList.appendChild(li);
		});
		statusMessage.textContent = blocks.length ? 'Range blocks:' : 'No blocks found.';
		mw.hook('wikipage.content').fire($(contentContainer));
	}

	// query API for blocks
	async function getBlockLogs(api, range) {
		const response = await api.get({
			action: 'query',
			list: 'logevents',
			letype: 'block',
			letitle: `User:${range}`,
			format: 'json'
		});
		return response.query.logevents.map(event => ({
			logid: event.logid,
			timestamp: event.timestamp,
			user: event.user,
			action: event.action,
			comment: event.comment || '',
			params: event.params || {},
			url: mw.util.getUrl('Special:Log', { logid: event.logid }),
			range: range,
		}));
	}

	// extract IP address
	function extractIP(userName) {
		const IPV4REGEX = /^((?:1?\d\d?|2[0-2]\d)\b(?:\.(?:1?\d\d?|2[0-4]\d|25[0-5])){3})(?:\/(1[6-9]|2\d|3[0-2]))?$/;
		const IPV6REGEX = /^((?:[\dA-Fa-f]{1,4}:){2,7}(?:[\dA-Fa-f]{1,4}|:)?)(?:\/(19|[2-9]\d|1[01]\d|12[0-8]))?$/;
		const match = IPV4REGEX.exec(userName) || IPV6REGEX.exec(userName);
		if (match) {
			const version = match[1].includes(':') ? 6 : 4;
			const ip = match[1];
			const mask = parseInt(match[2] || (version === 6 ? '128' : '32'), 10);
			return { version, ip, mask };
		}
		return null;
	}

	// convert full IPv6 address to BigInt
	function ipv6ToBigInt(ipv6) {
		const segments = ipv6.split(':');
		let bigIntValue = 0n;
		const expanded = expandIPv6(segments);
		expanded.forEach(segment => {
			bigIntValue = (bigIntValue << 16n) + BigInt(parseInt(segment, 16));
		});
		return bigIntValue;
	}

	// expand shorthand IPv6 (e.g., '::1' to '0:0:0:0:0:0:0:1')
	function expandIPv6(segments) {
		const expanded = [];
		let hasEmpty = false;
		segments.forEach(segment => {
			if (segment === '' && !hasEmpty) {
				expanded.push(...Array(8 - segments.filter(s => s).length).fill('0'));
				hasEmpty = true;
			} else if (segment === '') {
				expanded.push('0');
			} else {
				expanded.push(segment);
			}
		});
		return expanded.map(seg => seg.padStart(4, '0'));
	}

	// apply mask to BigInt for IPv6
	function applyMask(bigIntValue, prefixLength) {
		const maskBits = 128 - prefixLength;
		const mask = (1n << BigInt(128 - maskBits)) - 1n;
		return bigIntValue & (mask << BigInt(maskBits));
	}

	// convert BigInt back to IPv6 string
	function bigIntToIPv6(bigIntValue, prefixLength = 128) {
		const segments = [];
		for (let i = 0; i < 8; i++) {
			const segment = (bigIntValue >> BigInt((7 - i) * 16)) & 0xffffn;
			segments.push(segment.toString(16));
		}
		const ipv6 = segments.join(':').replace(/(^|:)0(:0)+(:|$)/, '::');
		return ipv6 + (prefixLength < 128 ? `/${prefixLength}` : '');
	}

	// generate masked IPv6 range
	function maskedIPv6(ipv6, prefixLength) {
		const bigIntValue = ipv6ToBigInt(ipv6);
		const maskedBigInt = applyMask(bigIntValue, prefixLength);
		return bigIntToIPv6(maskedBigInt, prefixLength);
	}

	// generate masked IPv4 range
	function maskedIPv4(ipv4, prefixLength) {
		const segments = ipv4.split('.').map(Number);
		const ipInt = (segments[0] << 24) | (segments[1] << 16) | (segments[2] << 8) | segments[3];
		const mask = (1 << (32 - prefixLength)) - 1;
		const maskedIpInt = ipInt & ~mask;
		return [
			(maskedIpInt >>> 24) & 0xff,
			(maskedIpInt >>> 16) & 0xff,
			(maskedIpInt >>> 8) & 0xff,
			maskedIpInt & 0xff
		].join('.') + `/${prefixLength}`;
	}

	// generate HTML for a block log entry
	async function formatBlockEntry(block) {
		function translateFlags(flags) {
			const flagMap = {
				'anononly': 'anon. only',
				'nocreate': 'account creation blocked',
				'nousertalk': 'cannot edit own talk page',
			};
			return flags.map(flag => flagMap[flag] || flag).join(', ');
		}
		const formattedTimestamp = formatTimeAndDate(new Date(block.timestamp));
		const logLink = `<a href="/w/index.php?title=Special:Log&logid=${block.logid}" title="Special:Log">${formattedTimestamp}</a>`;
		const userLink = `<a href="/wiki/User:${block.user}" title="User:${block.user}"><bdi>${block.user}</bdi></a>`;
		const userTools = `(<a href="/wiki/User_talk:${block.user}" title="User talk:${block.user}">talk</a> | <span><a href="/wiki/Special:Contributions/${block.user}" title="Special:Contributions/${block.user}">contribs</a>)`;
		const action = block.action === "reblock" ? "changed block settings for" : `${block.action}ed`;
		const ipLink = `<a href="/wiki/Special:Contributions/${block.range}" title=""><bdi>${block.range}</bdi></a>`;
		let expiryTime = '';
		if (block.action !== "unblock") {
			let expiryTimeStr = block.params?.duration;
			if (!expiryTimeStr || ['infinite', 'indefinite', 'infinity'].includes(expiryTimeStr)) {
				expiryTimeStr = 'indefinite';
			} else if (!isNaN(Date.parse(expiryTimeStr))) {
				const expiryDate = new Date(expiryTimeStr);
				expiryTimeStr = formatTimeAndDate(expiryDate);
			}
			expiryTime = ` with an expiration time of <span class="blockExpiry" title="${block.params?.duration || 'indefinite'}">${expiryTimeStr}</span>`;
		}
		const translatedFlags = block.params?.flags && block.params.flags.length ? ` (${translateFlags(block.params.flags)})` : '';
		const comment = block.comment ? ` <span class="comment" style="font-style: italic;">(${await wikitextToHTML(block.comment)})</span>` : '';
		const actionLinks = `(<a href="/wiki/Special:Unblock/${block.range}" title="Special:Unblock/${block.range}">unblock</a> | <a href="/wiki/Special:Block/${block.range}" title="Special:Block/${block.range}">change block</a>)`;
		return `${logLink} ${userLink} ${userTools} ${action} ${ipLink}${expiryTime}${translatedFlags}${comment} ${actionLinks}`;
	}

	// convert wikitext to HTML
	async function wikitextToHTML(wikitext) {
		if (wikitextCache.has(wikitext)) {
			return wikitextCache.get(wikitext);
		}
		try {
			wikitext = wikitext.replace(/{{/g, '\\{\\{').replace(/}}/g, '\\}\\}');
			const response = await api.post({
				action: 'parse',
				disableeditsection: true,
				prop: 'text',
				format: 'json',
				text: wikitext
			});
			if (response.parse && response.parse.text) {
				const pattern = new RegExp('^.*?<p>(.*)<\/p>.*$', 's');
				const html = response.parse.text['*']
					.replace(pattern, '$1')
					.replace(/\\{\\{/g, '{{')
					.replace(/\\}\\}/g, '}}')
					.trim();
				wikitextCache.set(wikitext, html);
				return html;
			}
		} catch (error) {
			console.error('Error converting wikitext to HTML:', error);
		}
		return wikitext;
	}
});