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 09:51, 25 April 2025 (add partial block support). 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
	const pageName = mw.config.get('wgPageName');
	const specialPage = mw.config.get('wgCanonicalSpecialPageName');
	if (specialPage === 'Contributions') {
		const userToolsBDI = document.querySelector('.mw-contributions-user-tools bdi');
		const userName = userToolsBDI ? userToolsBDI.textContent.trim() : '';
		const ip = extractIP(userName);
		if (ip) {
			addContributionsLinks(ip);
			if (ip.mask !== (ip.version === 6 ? 128 : 32)) {
				mw.util.addPortletLink('p-tb', '/wiki/Special:BlankPage/RangeCalculator', 'Range calculator');
				mw.util.addPortletLink('p-tb', '#', 'Range selector')
					.addEventListener('click', event => {
						event.preventDefault();
						startRangeSelection();
					});
			}
		}
	} else if (specialPage === 'Blankpage') {
		const match = pageName.match(/^Special:BlankPage\/(\w+)(?:\/(.+))?$/);
		if (match) {
			if (match[1] === 'RangeBlocks') {
				const ip = extractIP(match[2] || '');
				if (ip) {
					displayRangeBlocks(ip);
				}
			} else if (match[1] === 'RangeCalculator') {
				displayRangeCalculator();
			}
		}
	} else if (pageName === '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);
		});
	}

	// generate styled div for IP range calculation results
	function createRangeDisplay() {
		const display = document.createElement('div');
		display.id = 'range-display';
		display.style.fontWeight = 'bold';
		display.style.border = '1px solid var(--border-color-base, #a2a9b1)';
		display.style.borderRadius = '2px';
		display.style.padding = '16px';
		display.style.fontSize = '1rem';
		display.style.margin = '1em 0';
		return display;
	}

	// compute common IP range for IP list
	function computeCommonRange(ips) {
		if (!ips.length) {
			return '<span style="color:red;">No valid IPs found.</span>';
		}
		const firstVersion = ips[0].version;
		if (!ips.every(ip => ip.version === firstVersion)) {
			return '<span style="color:red;">Mixed IPv4 and IPv6 addresses are not supported.</span>';
		}
		const masks = firstVersion === 6 ? sequence(19, 64) : sequence(16, 32);
		const bestMask = masks.findLast(m => {
			const base = maskedIP(ips[0], m);
			return ips.every(ip => maskedIP(ip, m) === base);
		});
		if (!bestMask) {
			return '<span style="color:red;">No common range found.</span>';
		}
		const resultRange = maskedIP(ips[0], bestMask);
		const contribsLink = `<a href="/wiki/Special:Contributions/${resultRange}" target="_blank">${resultRange}</a>`;
		const blockLink = `<a href="/wiki/Special:Block/${resultRange}" target="_blank">block</a>`;
		return `<span>${ips.length} unique IP${ips.length === 1 ? '' : 's'}: ${contribsLink} (${blockLink})</span>`;
	}

	// standalone range calculator
	function displayRangeCalculator() {
		document.title = 'Range calculator';
		const heading = document.querySelector('#firstHeading');
		if (heading) {
			heading.innerHTML = 'Range calculator';
		}
		const contentContainer = document.querySelector('#mw-content-text');
		if (!contentContainer) return;
		contentContainer.innerHTML = '';
		const wrapper = document.createElement('div');
		wrapper.innerHTML = `
			<p>Calculate the smallest range that encompasses a given list of IP addresses.</p>
			<fieldset>
				<legend>Enter IP addresses (one per line or space-separated)</legend>
				<textarea id="range-input" rows="10" style="width: 100%"></textarea>
			</fieldset>
			<div style="margin-top:10px;">
				<button id="range-calculate">Calculate Range</button>
			</div>
		`;
		contentContainer.appendChild(wrapper);
		document.getElementById('range-calculate').addEventListener('click', event => {
			event.preventDefault();
			let results = document.getElementById('range-display');
			if (!results) {
				results = createRangeDisplay();
				wrapper.appendChild(results);
			}
			const input = document.getElementById('range-input').value;
			const ipRegex = /\b(?:\d{1,3}(?:\.\d{1,3}){3})\b|\b(?:[\dA-Fa-f]{1,4}:){4,}[\dA-Fa-f:]+/g;
			const matches = [...input.matchAll(ipRegex)].map(m => m[0]);
			const ips = [];
			for (const match of matches) {
				const ip = extractIP(match);
				if (ip) {
					ipListAdd(ips, ip);
				}
			}
			results.innerHTML = computeCommonRange(ips);
		});
	}

	// select IPs to compute common IP range
	function startRangeSelection() {
		function updateRangeDisplay() {
			if (!selectedIPs.length) {
				display.innerHTML = 'No IPs selected.';
			} else {
				display.innerHTML = computeCommonRange(selectedIPs);
			}
		}
		if (document.getElementById('range-display')) return;
		const selectedIPs = [];
		const display = createRangeDisplay();
		updateRangeDisplay();
		document.querySelector('#mw-content-text')?.prepend(display);
		document.querySelectorAll('a.mw-anonuserlink').forEach(link => {
			const checkbox = document.createElement('input');
			checkbox.type = 'checkbox';
			checkbox.style.marginLeft = '0.5em';
			checkbox.addEventListener('change', () => {
				const ipText = link.textContent.trim();
				const ip = extractIP(ipText);
				if (!ip) return;
				if (checkbox.checked) {
					ipListAdd(selectedIPs, ip);
				} else {
					ipListRemove(selectedIPs, ip);
				}
				updateRangeDisplay();
			});
			link.parentNode?.insertBefore(checkbox, link.nextSibling);
		});
	}

	// find range blocks
	async function displayRangeBlocks(ip) {
		api = new mw.Api();
		formatTimeAndDate = mw.loader.require('mediawiki.DateFormatter').formatTimeAndDate;
		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 = '';
		const statusMessage = document.createElement('p');
		statusMessage.innerHTML = `Querying logs for IP blocks affecting <a href="/wiki/Special:Contributions/${ip.ip}">${ip.ip}</a>...`;
		contentContainer.appendChild(statusMessage);
		const resultsList = document.createElement('ul');
		contentContainer.appendChild(resultsList);
		const masks = ip.version === 6 ? sequence(19, 64) : sequence(16, 31);
		const blocks = [];
		const blockPromises = masks.map(mask => {
			const range = maskedIP(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);
		});
		if (!blocks.length) {
			statusMessage.innerHTML = '<span style="color:red;">No blocks found.</span>';
		} else {
			statusMessage.innerHTML = `Range blocks for <a href="/wiki/Special:Contributions/${ip.ip}">${ip.ip}</a>`;
		}
		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}:){7}[\dA-Fa-f]{1,4}|(?:[\dA-Fa-f]{1,4}:){1,7}:|(?:[\dA-Fa-f]{1,4}:){1,6}:[\dA-Fa-f]{1,4}|(?:[\dA-Fa-f]{1,4}:){1,5}(?:\:[\dA-Fa-f]{1,4}){1,2}|(?:[\dA-Fa-f]{1,4}:){1,4}(?:\:[\dA-Fa-f]{1,4}){1,3}|(?:[\dA-Fa-f]{1,4}:){1,3}(?:\:[\dA-Fa-f]{1,4}){1,4}|(?:[\dA-Fa-f]{1,4}:){1,2}(?:\:[\dA-Fa-f]{1,4}){1,5}|[\dA-Fa-f]{1,4}:(?:(?:\:[\dA-Fa-f]{1,4}){1,6}))(?:\/(19|[2-9]\d|1[01]\d|12[0-8]))?$/; // based on https://stackoverflow.com/a/17871737
		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;
	}

	// generate sequence of numbers
	function sequence(n, m, step = 1) {
		for (var i = n, r = []; i <= m; i += step) r.push(i);
		return r;
	}

	// add IP to array
	function ipListAdd(ipList, ip) {
		if (!ipList.some(i => i.ip === ip.ip && i.mask === ip.mask && i.version === ip.version)) {
			ipList.push(ip);
		}
	}

	// remove IP from array
	function ipListRemove(ipList, ip) {
		const index = ipList.findIndex(i => i.ip === ip.ip && i.mask === ip.mask && i.version === ip.version);
		if (index !== -1) {
			ipList.splice(index, 1);
		}
	}

	// 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 IP range
	function maskedIP(ip, prefixLength) {
		return ip.version === 6 ? maskedIPv6(ip.ip, prefixLength) : maskedIPv4(ip.ip, prefixLength);
	}

	// generate masked IPv6 range
	function maskedIPv6(ipv6, prefixLength) {
		const bigIntValue = ipv6ToBigInt(ipv6);
		const shift = 128 - prefixLength;
		const mask = (1n << BigInt(128 - shift)) - 1n;
		const masked = bigIntValue & (mask << BigInt(shift));
		return bigIntToIPv6(masked, prefixLength);
	}

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

	// generate HTML for a block log entry
	async function formatBlockEntry(block) {
		function textList(items) {
			if (!items || items.length === 0) return '';
			if (items.length === 1) return items[0];
			if (items.length === 2) return `${items[0]} and ${items[1]}`;
			return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`;
		}
		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 restrictions = '';
		if (block.params?.restrictions) {
			const pages = block.params.restrictions?.pages || [];
			const namespaces = block.params.restrictions?.namespaces || [];
			const pageLinks = pages.map(page =>
				`<a href="/wiki/${page.page_title}" title="${page.page_title}">${page.page_title}</a>`
			);
			const nsLinks = namespaces.map(ns => {
				const prefix = mw.config.get('wgFormattedNamespaces')[ns];
				const display = ns === 0 ? 'Article' : (prefix || `${ns}`);
				return `<a href="/w/index.php?title=Special:AllPages&namespace=${ns}" title="Special:AllPages">(${display})</a>`;
			});
			const pageText = pageLinks.length ? ` from the page${pages.length === 1 ? '' : 's'} ${textList(pageLinks)} ` : '';
			const nsText = nsLinks.length ? ` from the namespace${namespaces.length === 1 ? '' : 's'} ${textList(nsLinks)} ` : '';
			if (pageText && nsText) {
				restrictions = `${pageText}and${nsText}`;
			} else {
				restrictions = pageText || nsText;
			}
		}
		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}${restrictions}${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;
	}
});