User:Daniel Quinlan/Scripts/RangeHelper.js: Difference between revisions

Content deleted Content added
minor fixes
refactor IP code into IPAddress class
Line 1:
class IPAddress {
static from(input) {
if (typeof input !== 'string') return null;
try {
const parsed = IPAddress.#parse(input);
return parsed ? new IPAddress(parsed) : null;
} catch {
return null;
}
}
 
constructor({ version, ip, mask }) {
this.version = version;
this.ip = ip;
this.mask = mask;
this.effectiveMask = mask ?? (version === 4 ? 32 : 128);
}
 
equals(other) {
return other instanceof IPAddress &&
this.version === other.version &&
this.ip === other.ip &&
this.effectiveMask === other.effectiveMask;
}
 
masked(prefixLength) {
const size = this.version === 4 ? 32 : 128;
const mask = (1n << BigInt(size - prefixLength)) - 1n;
const maskedIP = this.ip & ~mask;
return new IPAddress({
ip: maskedIP,
mask: prefixLength,
version: this.version
});
}
 
toString(uppercase = true, compress = false) {
let ipString = this.version === 4
? IPAddress.#bigIntToIPv4(this.ip)
: IPAddress.#bigIntToIPv6(this.ip);
if (compress && this.version === 6) {
ipString = IPAddress.#compressIPv6(ipString);
}
if (this.mask !== null) {
ipString += `/${this.mask}`;
}
return uppercase ? ipString.toUpperCase() : ipString;
}
 
static #parse(input) {
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(input) || IPV6REGEX.exec(input);
if (match) {
const version = match[1].includes(':') ? 6 : 4;
const ip = version === 4 ? IPAddress.#ipv4ToBigInt(match[1]) : IPAddress.#ipv6ToBigInt(match[1]);
const mask = match[2] ? parseInt(match[2], 10) : null;
return { version, ip, mask };
}
return null;
}
 
static #ipv4ToBigInt(ipv4) {
const octets = ipv4.split('.').map(BigInt);
return (octets[0] << 24n) | (octets[1] << 16n) | (octets[2] << 8n) | octets[3];
}
 
static #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'));
}
 
static #ipv6ToBigInt(ipv6) {
const segments = ipv6.split(':');
let bigIntValue = 0n;
const expanded = IPAddress.#expandIPv6(segments);
expanded.forEach(segment => {
bigIntValue = (bigIntValue << 16n) + BigInt(parseInt(segment, 16));
});
return bigIntValue;
}
 
static #bigIntToIPv4(bigIntValue) {
return [
(bigIntValue >> 24n) & 255n,
(bigIntValue >> 16n) & 255n,
(bigIntValue >> 8n) & 255n,
bigIntValue & 255n,
].join('.');
}
 
static #bigIntToIPv6(bigIntValue) {
const segments = [];
for (let i = 0; i < 8; i++) {
const segment = (bigIntValue >> BigInt((7 - i) * 16)) & 0xffffn;
segments.push(segment.toString(16));
}
return segments.join(':')
}
 
static #compressIPv6(ipv6) {
const zeroBlocks = ipv6.match(/\b0(?:\:0)+\b/g);
if (!zeroBlocks) return ipv6;
const longestZeroBlock = zeroBlocks.reduce((longest, current) => {
return current.length > longest.length ? current : longest;
}, "");
if (longestZeroBlock) {
return ipv6.replace(new RegExp(`:?\\b${longestZeroBlock}\\b:?`), '::');
}
return ipv6;
}
}
 
mw.loader.using(['mediawiki.api', 'mediawiki.util', 'mediawiki.DateFormatter']).then(function() {
// state variables
Line 11 ⟶ 135:
const userToolsBDI = document.querySelector('.mw-contributions-user-tools bdi');
const userName = userToolsBDI ? userToolsBDI.textContent.trim() : '';
const ip = extractIPIPAddress.from(userName);
if (ip) {
addContributionsLinks(ip, userName.includes('/'));
}
} else if (specialPage === 'Blankpage') {
const match = pageName.match(/^Special:BlankPage\/(\w+)(?:\/(.+))?$/);
if (!match) {return;
if (match[1] === 'RangeBlocks') {
const ip = extractIPIPAddress.from(match[2] || '');
if (ip) {
displayRangeBlocks(ip);
}
} else if (match[1] === 'RangeCalculator') {
displayRangeCalculator();
}
} else if (match[1] === 'RangeCalculator') {
displayRangeCalculator();
}
} else if (pageName === 'Special:Log/block') {
Line 31 ⟶ 154:
if (pageParam) {
const match = pageParam.match(/^User:(.+)$/);
if (!match) {return;
const ip = extractIPIPAddress.from(match[1]);
if (ip) {
mw.util.addPortletLink('p-tb', `/wiki/Special:BlankPage/RangeBlocks/${ip}`, "Find range blocks");
let rangeIP = maskedIP(ip, ip.mask, false).toUpperCase();
if (!pageParam.includes('/')) {
rangeIP = rangeIP.split('/')[0];
}
mw.util.addPortletLink('p-tb', `/wiki/Special:BlankPage/RangeBlocks/${rangeIP}`, "Find range blocks");
}
}
}
Line 46 ⟶ 164:
 
// adds links to user tools
function addContributionsLinks(ip, isRange) {
const userToolsContainer = document.querySelector('.mw-contributions-user-tools .mw-changeslist-links');
if (!userToolsContainer) return;
Line 52 ⟶ 170:
if (blockLogLink) {
const rangeLogLink = document.createElement('a');
const rangeLogPage = `Special:BlankPage/RangeBlocks/${ip}`;
let rangeIP = maskedIP(ip, ip.mask, false).toUpperCase();
if (!isRange) {
rangeIP = rangeIP.split('/')[0];
}
const rangeLogPage = `Special:BlankPage/RangeBlocks/${rangeIP}`;
rangeLogLink.href = `/wiki/${rangeLogPage}`;
rangeLogLink.textContent = '(ranges)';
Line 75 ⟶ 189:
}
if (!insertBefore) return;
letconst floor = 16, ceilingip.version === 24,4 steps? =16 : 832;
ifconst ceiling = Math.min(ip.version === 6)4 {? 24 : 64, ip.effectiveMask - 1);
const steps = ip.effectiveMask >= 64 ? 16 : 8;
floor = 32;
for (let mask = floor; mask <= ceiling; mask += steps) {
ceiling = 64;
if (ip.mask >= 64)
steps = 16;
}
for (let mask = floor; mask <= ceiling && mask < ip.mask; mask += steps) {
const contribsLink = document.createElement('a');
contribsLink.href = `/wiki/Special:Contributions/${ip.masked(mask)}`;
const contribsIP = maskedIP(ip, mask, false).toUpperCase();
contribsLink.href = `/wiki/Special:Contributions/${contribsIP}`;
contribsLink.textContent = `/${mask}`;
contribsLink.className = 'mw-contributions-link-range-suggestion';
Line 92 ⟶ 201:
userToolsContainer.insertBefore(span, insertBefore);
}
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')
Line 124 ⟶ 233:
return '<span style="color:red;">Mixed IPv4 and IPv6 addresses are not supported.</span>';
}
const masks = firstVersion === 64 ? sequence(1916, 6432) : sequence(1619, 3264);
const bestMask = masks.findLast(m => {
const base = maskedIP(ips[0], .masked(m);
return ips.every(ip => maskedIP(ip, .masked(m) === .equals(base));
});
if (!bestMask) {
return '<span style="color:red;">No common range found.</span>';
}
const resultRange = maskedIP(ips[0].masked(bestMask).toString(false, bestMasktrue);
const contribsLink = `<a href="/wiki/Special:Contributions/${resultRange}" target="_blank">${resultRange}</a>`;
const blockLink = `<a href="/wiki/Special:Block/${resultRange}" target="_blank">block</a>`;
Line 169 ⟶ 278:
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 = [];
input.matchAll(ipRegex)
for (const match of matches) {
const ip.map(match => extractIPIPAddress.from(match[0]));
if .filter(ipBoolean) {
.forEach(ip => ipListAdd(ips, ip));
}
}
results.innerHTML = computeCommonRange(ips);
});
Line 201 ⟶ 307:
checkbox.addEventListener('change', () => {
const ipText = link.textContent.trim();
const ip = extractIPIPAddress.from(ipText);
if (!ip) return;
if (checkbox.checked) {
Line 218 ⟶ 324:
api = new mw.Api();
formatTimeAndDate = mw.loader.require('mediawiki.DateFormatter').formatTimeAndDate;
document.title = `Range blocks for ${ip}`;
const rangeIP = maskedIP(ip, ip.mask, false).toUpperCase();
document.title = `Range blocks for ${rangeIP}`;
const heading = document.querySelector('#firstHeading');
if (heading) {
heading.innerHTML = `Range blocks for ${rangeIPip}`;
}
const contentContainer = document.querySelector('#mw-content-text');
Line 228 ⟶ 333:
contentContainer.innerHTML = '';
const statusMessage = document.createElement('p');
statusMessage.innerHTML = `Querying logs for IP blocks affecting <a href="/wiki/Special:Contributions/${rangeIPip}">${rangeIPip}</a>...`;
contentContainer.appendChild(statusMessage);
const resultsList = document.createElement('ul');
contentContainer.appendChild(resultsList);
const masks = ip.version === 64 ? sequence(1916, 6431) : sequence(1619, 3164);
if (!masks.includes(ip.maskeffectiveMask)) {
masks.push(ip.maskeffectiveMask);
}
const blocks = [];
const blockPromises = masks.map(mask => {
const range = maskedIP(ip.masked(mask).toString(false, masktrue);
return getBlockLogs(api, range).then(async (blockLogs) => {
for (const block of blockLogs) {
Line 258 ⟶ 363:
statusMessage.innerHTML = '<span style="color:red;">No blocks found.</span>';
} else {
statusMessage.innerHTML = `Range blocks for <a href="/wiki/Special:Contributions/${rangeIPip}">${rangeIPip}</a>`;
}
mw.hook('wikipage.content').fire($(contentContainer));
Line 280 ⟶ 385:
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;
}
 
Line 306 ⟶ 397:
// add IP to array
function ipListAdd(ipList, ip) {
if (!ipList.some(i => i.equals(ip))) === ipipList.push(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.equals(ip === ip.ip && i.mask === ip.mask && i.version === ip.version));
if (index !== -1) {ipList.splice(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, compress = true) {
function compressIPv6(ipv6) {
const zeroBlocks = ipv6.match(/\b0(?:\:0)+\b/g);
if (!zeroBlocks) return ipv6;
const longestZeroBlock = zeroBlocks.reduce((longest, current) => {
return current.length > longest.length ? current : longest;
}, "");
if (longestZeroBlock) {
return ipv6.replace(new RegExp(`:?\\b${longestZeroBlock}\\b:?`), '::');
}
return ipv6;
}
const segments = [];
for (let i = 0; i < 8; i++) {
const segment = (bigIntValue >> BigInt((7 - i) * 16)) & 0xffffn;
segments.push(segment.toString(16));
}
let ipv6 = segments.join(':')
if (compress) {
ipv6 = compressIPv6(ipv6);
}
return ipv6 + (prefixLength < 128 ? `/${prefixLength}` : '');
}
 
// generate masked IP range
function maskedIP(ip, prefixLength, compress = true) {
return ip.version === 6 ? maskedIPv6(ip.ip, prefixLength, compress) : maskedIPv4(ip.ip, prefixLength);
}
 
// generate masked IPv6 range
function maskedIPv6(ipv6, prefixLength, compress = true) {
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, compress);
}
 
// 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}`;
}