Jump to content

User:GeneralNotability/InvestorGoat.js

From Wikipedia, the free encyclopedia
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.
// <nowiki>
// InvestorGoat, a suite of CheckUser tools.
// Parts taken from https://en.wikipedia.org/wiki/User:Firefly/checkuseragenthelper.js
/* global mw, $, UAParser */

$(async function ($) {
  if (mw.config.get('wgCanonicalSpecialPageName') && mw.config.get('wgCanonicalSpecialPageName').startsWith('CheckUser')) {
    await mw.loader.using('mediawiki.util')
    InvestorGoatPrepUAs()
    mw.hook('wikipage.content').add(InvestorGoatIPHook)
    mw.hook('wikipage.content').add(InvestorGoatAddQuickReasonBoxHook)
  }
  mw.hook('wikipage.content').add(InvestorGoatHighlightLogs)
})

const InvestorGoatwmbApiKey = mw.user.options.get('userjs-wmbkey')
const InvestorGoatUAMap = new Map()
let InvestorGoatCompareTarget = null

async function InvestorGoatPrepUAs () {
  await mw.loader.getScript('https://en.wikipedia.org/w/index.php?title=User:GeneralNotability/ua-parser.min.js&action=raw&ctype=text/javascript')
  $('.mw-checkuser-agent').each(async function () {
    const $el = $(this)
    const ua = $el.text()

    // Add indicators
    $('<span>').addClass('InvestorGoat-device').text('DEV').css({ margin: '2px', 'user-select': 'none' }).appendTo($el.parent())
    $('<span>').addClass('InvestorGoat-OS').text('OS').css({ margin: '2px', 'user-select': 'none' }).appendTo($el.parent())
    $('<span>').addClass('InvestorGoat-browser').text('BR').css({ margin: '2px', 'user-select': 'none' }).appendTo($el.parent())
    if (InvestorGoatwmbApiKey) {
      if (!InvestorGoatUAMap.has(ua)) {
        InvestorGoatUAMap.set(ua, null)
      }
    } else {
      const parser = new UAParser()
      if (!InvestorGoatUAMap.has(ua)) {
        parser.setUA(ua)
        const uaObj = parser.getResult()
        InvestorGoatUAMap.set(ua, uaObj)
      }
    }

    // Set up dummy link
    const $link = $('<a>').attr('href', '#').on('click', InvestorGoatUAClicked)
    $link.insertAfter($el)
    $el.detach()
    $link.append($el)
  })

  if (InvestorGoatwmbApiKey && InvestorGoatUAMap.size > 0) {
    // Do a batch query if we're using the WhatIsMyBrowser API
    const query = {}
    query.user_agents = {}
    query.parse_options = {
      return_metadata_for_useragent: true
    }
    for (const [key, _] of InvestorGoatUAMap.entries()) {
      query.user_agents[key] = key
    }
    const result = await $.ajax({
      type: 'post',
      url: 'https://api.whatismybrowser.com/api/v2/user_agent_parse_batch',
      data: JSON.stringify(query),
      headers: {
        'x-api-key': InvestorGoatwmbApiKey
      }
    })
    console.log(result)
    for (const [key, value] of Object.entries(result.parses)) {
      InvestorGoatUAMap.set(key, value)
    }

    // Flag interesting results
    $('.mw-checkuser-agent').each(async function () {
      const $el = $(this)
      const ua = $el.text()
      const val = InvestorGoatUAMap.get(ua)
      // Add indicators
      $('<span>').addClass('InvestorGoat-seen').text('#️⃣').css({ margin: '2px', 'user-select': 'none' })
        .attr('title', `Estimated popularity: ${Math.pow(10, Math.floor(Math.log10(val.user_agent_metadata.times_seen))).toLocaleString()}s`).appendTo($el.parent().parent())
      if (val.parse.is_weird) {
        $('<span>').addClass('InvestorGoat-weird').text('❓').css({ margin: '2px', 'user-select': 'none' })
          .attr('title', `UA flagged as weird: ${val.parse.is_weird_reason_code}`).appendTo($el.parent().parent())
      }
      if (val.parse.is_abusive) {
        $('<span>').addClass('InvestorGoat-abusive').text('‼️').css({ margin: '2px', 'user-select': 'none' })
          .attr('title', 'UA flagged as abusive').appendTo($el.parent().parent())
      }
      if (val.parse.is_spam) {
        $('<span>').addClass('InvestorGoat-spam').text('🥫').css({ margin: '2px', 'user-select': 'none' })
          .attr('title', 'UA flagged as spam').appendTo($el.parent().parent())
      }
      if (val.parse.is_restricted) {
        $('<span>').addClass('InvestorGoat-restricted').text('🛑').css({ margin: '2px', 'user-select': 'none' })
          .attr('title', 'UA flagged as restricted').appendTo($el.parent().parent())
      }
      if (val.parse.software_type === 'bot') {
        $('<span>').addClass('InvestorGoat-bot').text('🤖').css({ margin: '2px', 'user-select': 'none' })
          .attr('title', `UA flagged as bot: ${val.parse.software_sub_type}`).appendTo($el.parent().parent())
      }
    })
  }
}

async function InvestorGoatUAClicked (event) {
  InvestorGoatCompareTarget = InvestorGoatUAMap.get($(event.target).text())
  if (InvestorGoatwmbApiKey) {
    InvestorGoatUpdateUAColorsWmb()
  } else {
    InvestorGoatUpdateUAColors()
  }
  event.preventDefault()
}

async function InvestorGoatUpdateUAColors () {
  const match = { backgroundColor: 'DarkGreen', color: 'white' }
  const familyMismatch = { backgroundColor: 'DarkRed', color: 'white' }
  const majorVersionSelectedAhead = { backgroundColor: 'orange', color: 'black' }

  $('.mw-checkuser-agent').each(async function () {
    const $el = $(this)
    const uaObj = InvestorGoatUAMap.get($el.text())

    const $devEl = $el.parent().parent().children('.InvestorGoat-device')
    if (uaObj.device.type !== InvestorGoatCompareTarget.device.type) {
      $devEl.css(familyMismatch).attr('title', `Device type mismatch, this is ${uaObj.device.type}, selected is ${InvestorGoatCompareTarget.device.type}`)
    } else if (uaObj.device.vendor !== InvestorGoatCompareTarget.device.vendor) {
      $devEl.css(majorVersionSelectedAhead).attr('title', `Device vendor mismatch, this is ${uaObj.device.vendor}, selected is ${InvestorGoatCompareTarget.device.vendor}`)
    } else if (uaObj.device.version !== InvestorGoatCompareTarget.device.version) {
      $devEl.css(majorVersionSelectedAhead).attr('title', `Device version mismatch, this is ${uaObj.device.version}, selected is ${InvestorGoatCompareTarget.device.version}`)
    } else {
      $devEl.css(match).attr('title', 'Devices match')
    }

    const $osEl = $el.parent().parent().children('.InvestorGoat-OS')
    if (uaObj.os.name !== InvestorGoatCompareTarget.os.name) {
      $osEl.css(familyMismatch).attr('title', `OS mismatch, selected is ${InvestorGoatCompareTarget.os.name}`)
    } else if (uaObj.os.version !== InvestorGoatCompareTarget.os.version) {
      $osEl.css(majorVersionSelectedAhead).attr('title', `OS version mismatch, selected is ${InvestorGoatCompareTarget.os.version}`)
    } else {
      $osEl.css(match).attr('title', 'OSes match')
    }

    const $browserEl = $el.parent().parent().children('.InvestorGoat-browser')
    if (uaObj.browser.name !== InvestorGoatCompareTarget.browser.name) {
      $browserEl.css(familyMismatch).attr('title', `Browser mismatch, selected is ${InvestorGoatCompareTarget.browser.name}`)
    } else if (uaObj.browser.version !== InvestorGoatCompareTarget.browser.version) {
      $browserEl.css(majorVersionSelectedAhead).attr('title', `Browser version mismatch, selected is ${InvestorGoatCompareTarget.browser.version}`)
    } else {
      $browserEl.css(match).attr('title', 'Browsers match')
    }
  })
}

async function InvestorGoatUpdateUAColorsWmb () {
  const match = { backgroundColor: 'DarkGreen', color: 'white' }
  const totalMismatch = { backgroundColor: 'DarkRed', color: 'white' }
  const majorMismatch = { backgroundColor: 'orange', color: 'black' }
  const minorMismatch = { backgroundColor: 'Yellow', color: 'black' }

  const versionSelectedBehind = { backgroundColor: 'DarkBlue', color: 'white' }
  const versionSelectedAhead = { backgroundColor: 'Yellow', color: 'black' }

  $('.mw-checkuser-agent').each(async function () {
    const $el = $(this)
    const uaObj = InvestorGoatUAMap.get($el.text())

    const $devEl = $el.parent().parent().children('.InvestorGoat-device')
    if (uaObj.parse.hardware_type !== InvestorGoatCompareTarget.parse.hardware_type) {
      $devEl.css(totalMismatch).attr('title', `Device type mismatch, this is "${uaObj.parse.hardware_type}", selected is "${InvestorGoatCompareTarget.parse.hardware_type}"`)
    } else if (uaObj.parse.hardware_sub_type !== InvestorGoatCompareTarget.parse.hardware_sub_type) {
      $devEl.css(majorMismatch).attr('title', `Device subtype mismatch, this is "${uaObj.parse.hardware_sub_type}", selected is "${InvestorGoatCompareTarget.parse.hardware_sub_type}"`)
    } else if (uaObj.parse.hardware_sub_sub_type !== InvestorGoatCompareTarget.parse.hardware_sub_sub_type) {
      $devEl.css(minorMismatch).attr('title', `Device sub-subtype mismatch, this is "${uaObj.parse.hardware_sub_sub_type}", selected is "${InvestorGoatCompareTarget.parse.hardware_sub_sub_type}"`)
    } else if (uaObj.parse.simple_operating_platform_string !== InvestorGoatCompareTarget.parse.simple_operating_platform_string) {
      $devEl.css(totalMismatch).attr('title', `Platform mismatch, this is "${uaObj.parse.simple_operating_platform_string}", selected is "${InvestorGoatCompareTarget.parse.simple_operating_platform_string}"`)
    } else {
      $devEl.css(match).attr('title', 'Devices match')
    }

    const $osEl = $el.parent().parent().children('.InvestorGoat-OS')
    if (uaObj.parse.operating_system_name_code !== InvestorGoatCompareTarget.parse.operating_system_name_code) {
      $osEl.css(totalMismatch).attr('title', `OS mismatch, this is "${uaObj.parse.operating_system_name}", selected is ${InvestorGoatCompareTarget.parse.operating_system_name}`)
    } else if (uaObj.parse.operating_system_flavour_code !== InvestorGoatCompareTarget.parse.operating_system_flavour_code) {
      $osEl.css(majorMismatch).attr('title', `OS flavor mismatch, this is "${uaObj.parse.operating_system_flavour}", selected is ${InvestorGoatCompareTarget.parse.operating_system_flavour}`)
    } else {
      let mismatch = false
      for (let i = 0; i < uaObj.parse.operating_system_version_full.length; i++) {
        if (i >= InvestorGoatCompareTarget.parse.operating_system_version_full.length) {
          break
        }
        if (parseInt(uaObj.parse.operating_system_version_full[i]) < parseInt(InvestorGoatCompareTarget.parse.operating_system_version_full[i])) {
          $osEl.css(versionSelectedAhead).attr('title', 'OS version mismatch, selected version is newer than this')
          mismatch = true
          break
        } else if (parseInt(uaObj.parse.operating_system_version_full[i]) > parseInt(InvestorGoatCompareTarget.parse.operating_system_version_full[i])) {
          $osEl.css(versionSelectedBehind).attr('title', 'OS version mismatch, selected version is older than this')
          mismatch = true
          break
        }
      }
      if (!mismatch) {
        $osEl.css(match).attr('title', 'OSes match')
      }
    }

    const $browserEl = $el.parent().parent().children('.InvestorGoat-browser')
    if (uaObj.parse.software_type !== InvestorGoatCompareTarget.parse.software_type) {
      $browserEl.css(totalMismatch).attr('title', `Software type mismatch, this is "${uaObj.parse.software_type}", selected is ${InvestorGoatCompareTarget.parse.software_type}`)
    } else if (uaObj.parse.software_sub_type !== InvestorGoatCompareTarget.parse.software_sub_type) {
      $browserEl.css(totalMismatch).attr('title', `Software subtype mismatch, this is "${uaObj.parse.software_sub_type}", selected is ${InvestorGoatCompareTarget.parse.software_sub_type}`)
    } else if (uaObj.parse.software_name_code !== InvestorGoatCompareTarget.parse.software_name_code) {
      $browserEl.css(totalMismatch).attr('title', `Browser mismatch, this is "${uaObj.parse.software_name}", selected is ${InvestorGoatCompareTarget.parse.software_name}`)
    } else {
      let mismatch = false
      for (let i = 0; i < uaObj.parse.software_version_full.length; i++) {
        if (i >= InvestorGoatCompareTarget.parse.software_version_full.length) {
          break
        }
        if (parseInt(uaObj.parse.software_version_full[i]) < parseInt(InvestorGoatCompareTarget.parse.software_version_full[i])) {
          $browserEl.css(versionSelectedAhead).attr('title', 'Browser version mismatch, selected version is newer than this')
          mismatch = true
          break
        } else if (parseInt(uaObj.parse.software_version_full[i]) > parseInt(InvestorGoatCompareTarget.parse.software_version_full[i])) {
          $browserEl.css(versionSelectedBehind).attr('title', 'Browser version mismatch, selected version is older than this')
          mismatch = true
          break
        }
      }
      if (!mismatch) {
        $browserEl.css(match).attr('title', 'Browsers match')
      }
    }
  })
}

const InvestorGoatSPECIALIPS = [
  { addr: '10.0.0.0', cidr: 8, color: 'red', hint: 'Internal IP' },
  { addr: '172.16.0.0', cidr: 12, color: 'red', hint: 'Internal IP' },
  { addr: '192.168.0.0', cidr: 16, color: 'red', hint: 'Internal IP' },
  { addr: '127.0.0.0', cidr: 8, color: 'red', hint: 'Loopback (WTF?)' },
  { addr: '185.15.56.0', cidr: 22, color: 'yellow', hint: 'WMF IP' },
  { addr: '91.198.174.0', cidr: 24, color: 'yellow', hint: 'WMF IP' },
  { addr: '198.35.26.0', cidr: 23, color: 'yellow', hint: 'WMF IP' },
  { addr: '208.80.152.0', cidr: 22, color: 'yellow', hint: 'WMF IP' },
  { addr: '103.102.166.0', cidr: 24, color: 'yellow', hint: 'WMF IP' },
  { addr: '143.228.0.0', cidr: 16, color: 'orange', hint: 'US Congress' },
  { addr: '12.185.56.0', cidr: 29, color: 'orange', hint: 'US Congress' },
  { addr: '12.147.170.144', cidr: 28, color: 'orange', hint: 'US Congress' },
  { addr: '74.119.128.0', cidr: 22, color: 'orange', hint: 'US Congress' },
  { addr: '156.33.0.0', cidr: 16, color: 'orange', hint: 'US Congress' },
  { addr: '165.119.0.0', cidr: 16, color: 'orange', hint: 'Executive Office of the President' },
  { addr: '198.137.240.0', cidr: 23, color: 'orange', hint: 'Executive Office of the President' },
  { addr: '204.68.207.0', cidr: 24, color: 'orange', hint: 'Executive Office of the President' },
  { addr: '149.101.0.0', cidr: 16, color: 'orange', hint: 'US Department of Justice' },
  { addr: '65.165.132.0', cidr: 24, color: 'orange', hint: 'US Dept of Homeland Security' },
  { addr: '204.248.24.0', cidr: 24, color: 'orange', hint: 'US Dept of Homeland Security' },
  { addr: '216.81.80.0', cidr: 20, color: 'orange', hint: 'US Dept of Homeland Security' },
  { addr: '131.132.0.0', cidr: 14, color: 'orange', hint: 'Canadian Dept of National Defence' },
  { addr: '131.136.0.0', cidr: 14, color: 'orange', hint: 'Canadian Dept of National Defence' },
  { addr: '131.140.0.0', cidr: 15, color: 'orange', hint: 'Canadian Dept of National Defence' },
  { addr: '192.197.82.0', cidr: 24, color: 'orange', hint: 'Canadian House of Commons' },
  { addr: '194.60.0.0', cidr: 18, color: 'orange', hint: 'UK Parliament' },
  { addr: '138.162.0.0', cidr: 16, color: 'orange', hint: 'US Department of the Navy' }
]

function InvestorGoatIsIPInRange (addr, targetRange, targetCidr) {
  // https://stackoverflow.com/questions/503052/javascript-is-ip-in-one-of-these-subnets
  const mask = -1 << (32 - +targetCidr) // eslint-disable-line no-bitwise
  if (mw.util.isIPv4Address(addr, false)) {
    const addrMatch = addr.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/)
    const addrInt = (+addrMatch[1] << 24) + (+addrMatch[2] << 16) + (+addrMatch[3] << 8) + // eslint-disable-line no-bitwise
      (+addrMatch[4]) // eslint-disable-line no-bitwise
    const targetMatch = targetRange.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/)
    const targetInt = (+targetMatch[1] << 24) + (+targetMatch[2] << 16) + (+targetMatch[3] << 8) + // eslint-disable-line no-bitwise
      (+targetMatch[4]) // eslint-disable-line no-bitwise
    return (addrInt & mask) === (targetInt & mask) // eslint-disable-line no-bitwise
  }
  // TODO: figure out ipv6
}

/**
 * Get all IP userlinks on the page
 *
 * @param {JQuery} $content page contents
 * @return {Map} list of unique users on the page and their corresponding links
 */
function InvestorGoatGetBlockLinks ($content) {
  const userLinks = new Map()

  const blockLinkRe = '^Special:Block/(.*)$'
  $('a', $content).each(function () {
    if (!$(this).attr('title')) {
      // Ignore if the <a> doesn't have a title
      return
    }
    const blockLinkMatch = $(this).attr('title').toString().match(blockLinkRe)
    if (!blockLinkMatch) {
      return
    }
    const user = decodeURIComponent(blockLinkMatch[1])
    if (mw.util.isIPAddress(user)) {
      if (!userLinks.get(user)) {
        userLinks.set(user, [])
      }
      userLinks.get(user).push($(this))
    }
  })
  return userLinks
}

async function InvestorGoatIPHook ($content) {
  const usersOnPage = InvestorGoatGetBlockLinks($content)
  usersOnPage.forEach(async (val, key, _) => {
    let color = ''
    let hint = ''
    for (const range of InvestorGoatSPECIALIPS) {
      if (InvestorGoatIsIPInRange(key, range.addr, range.cidr)) {
        color = range.color
        hint = range.hint
        break
      }
    }
    if (!color) {
      return
    }
    val.forEach(($link) => {
      $link.css({ backgroundColor: color })
      $link.attr('title', hint)
    })
  })
}

const InvestorGoatCHECKREASONS = [
  { label: 'Check type (optional)', selected: true, value: '', disabled: true },
  { label: 'SPI-related', selected: false, value: 'spi' },
  { label: 'Second Opinion', selected: false, value: '2o' },
  { label: 'Unblock Request', selected: false, value: 'unblock' },
  { label: 'IPBE Request', selected: false, value: 'ipbe' },
  { label: 'ACC Request', selected: false, value: 'acc' },
  { label: 'CU/Paid Queue', selected: false, value: 'q' },
  { label: 'Suspected known sockmaster', selected: false, value: 'sock' },
  { label: 'Suspected LTA', selected: false, value: 'lta' },
  { label: 'Comparison to ongoing CU', selected: false, value: 'comp' },
  { label: 'Collateral check', selected: false, value: 'coll' },
  { label: 'Cross-wiki request', selected: false, value: 'xwiki' },
  { label: 'Discretionary check', selected: false, value: 'fish' },
  { label: 'Self-check for testing', selected: false, value: 'test' }
]

function InvestorGoatAddQuickReasonBoxHook ($content) {
  const $select = $('<select>')
  for (const reason of InvestorGoatCHECKREASONS) {
    $('<option>')
      .val(reason.value)
      .prop('selected', reason.selected)
      .text(reason.label)
      .prop('disabled', reason.disabled)
      .appendTo($select)
  }

  $select.on('change', function (e) {
    InvestorGoatAddQuickReason($(e.target))
  })

  const $target = $('[name=reason]', $content)

  $select.insertBefore($target)
}

/**
 * Highlight CU log links where the log exists
 *
 * @param {JQuery} $content page contents
 */
function InvestorGoatHighlightLogs ($content) {
  const cuSearchTargetRe = 'cuSearch=(.*)'
  $('a.external.text', $content).each(async function () {
    if (!$(this).attr('href')) {
      // Ignore if the <a> doesn't have a title
      return
    }
    const cuTargetMatch = $(this).attr('href').toString().match(cuSearchTargetRe)
    if (!cuTargetMatch) {
      return
    }
    const target = decodeURIComponent(cuTargetMatch[1]).replaceAll('+', ' ')
    const api = new mw.Api()
    const request = {
      action: 'query',
      list: 'checkuserlog',
      cultarget: target,
      cullimit: 100
    }
    try {
      const response = await api.get(request)
      const filteredEntries = response.query.checkuserlog.entries.filter(entry =>
          !entry.target.includes('/') || parseInt(entry.target.split('/')[1]) >= 16)
      const checkCount = filteredEntries.length
      if (checkCount > 0) {
        $(this).attr('title', `${checkCount} checks`)
        let color = ''
        if (response.query.checkuserlog.entries[0].checkuser === mw.config.get('wgUserName')) {
          color = 'lightskyblue'
        } else {
          color = 'lightgreen'
        }
        // Have to use .style vice .css because .css doesn't understand !important
        $(this).attr('style', `background-color: ${color} !important`)
      }
    } catch (error) {
      console.log(`Error checking CU log: ${error}`)
    }
  })
}

function InvestorGoatAddQuickReason (source) {
  const $inputField = $('[name=reason]')
  $inputField.val('[' + source.val() + '] ' + $inputField.val())
}
// </nowiki>