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;
}
});