Source

lib/util/http.js

// Dependencies
const util = require('util')
let request = util.promisify(require('postman-request'))

// Includes
const options = require('../options.js')
const settings = require('../../settings.json')
const cache = require('../cache')
const getHash = require('./getHash.js').func

// Args
exports.required = ['url']
exports.optional = ['options', 'ignoreLoginError']

// Define
request = request.defaults({
  forever: true,
  agentOptions: {
    maxSockets: Infinity
  },
  simple: false,
  gzip: true,
  timeout: settings.timeout
})

// Docs
/**
 * ✅ Send an http request to url with options.
 * @category Utility
 * @alias http
 * @param {string} url - The url to request to.
 * @param {object} options - The options to send with the request.
 * @param {boolean} ignoreLoginError - If any login errors should be ignored.
 * @returns {Promise<string>}
 * @example const noblox = require("noblox.js")
 * const body = await noblox.http("https://roblox.com/login", { method: "GET" })
**/

function http (url, opt) {
  if (opt?.headers) {
    opt.headers = Object.fromEntries(
      Object.entries(opt.headers).map(([k, v]) => [k.toLowerCase(), v])
    )
  }
  if (opt && !opt.jar && Object.keys(opt).indexOf('jar') > -1) {
    opt.jar = options.jar
  }
  if (settings.session_only && opt && opt.jar) {
    if (!opt.headers) {
      opt.headers = {}
    }
    opt.headers.cookie = '.ROBLOSECURITY=' + opt.jar.session + ';'
    opt.headers['x-api-key'] = opt.jar.apiKey
    opt.jar = null
  }
  if (opt && opt.verification) {
    if (!opt.headers) {
      opt.headers = {}
    }
    const verify = '__RequestVerificationToken=' + opt.verification + ';'
    if (opt.headers.cookie) {
      opt.headers.cookie += verify
    } else {
      opt.headers.cookie = verify
    }
  }
  if (url.indexOf('http') !== 0) {
    url = 'https:' + url
  }

  /*
    In CI, actions does not allow us to use a static ip address (nor guarantees any particular location)
    This is a problem as Roblox locks sessions to a particular region
    Therefore, we intercept the request during testing and send it to the forwarder (which has a static ip address), the Roblox hostname is set as the Destination-Host header
    The ID token is for Google Cloud and allows authenticating with the Cloud Run service that acts as our forwarder
    Requests without headers do not go through the forwarder as they are unauthenticated and do not need forwarding
  */
  if (process?.env.CI && process.env.FORWARDER_HOSTNAME && opt.headers) {
    const urlObj = new URL(url)
    const { hostname } = urlObj

    opt.headers['Destination-Host'] = hostname
    opt.headers['x-serverless-authorization'] = `Bearer ${process.env.ID_TOKEN}`
    urlObj.hostname = process.env.FORWARDER_HOSTNAME
    url = urlObj.href
  }
  return request(url, opt)
}

exports.func = function (args) {
  const opt = args.options || {}
  if (typeof opt.jar === 'string') {
    opt.jar = { session: opt.jar }
  }
  const jar = opt.jar
  const depth = args.depth || 0
  const full = opt.resolveWithFullResponse || false
  opt.resolveWithFullResponse = true
  const follow = opt.followRedirect === undefined || opt.followRedirect
  opt.followRedirect = function (res) {
    if (!args.ignoreLoginError && res.headers.location && (res.headers.location.startsWith('https://www.roblox.com/newlogin') || res.headers.location.startsWith('/Login/Default.aspx'))) {
      return false
    }
    return follow
  }
  return http(args.url, opt).then(function (res) {
    if (res.statusCode === 403 && res.headers['x-csrf-token'] && Object.hasOwn(opt.headers ?? {}, 'x-csrf-token')) {
      if (depth >= 2) {
        throw new Error('Tried ' + (depth + 1) + ' times and could not refresh XCSRF token successfully')
      }

      const token = res.headers['x-csrf-token']

      if (token) {
        opt.headers['x-csrf-token'] = token
        opt.jar = jar
        args.depth = depth + 1
        return exports.func(args)
      } else {
        throw new Error('Could not refresh X-CSRF-TOKEN')
      }
    } else {
      if (depth > 0) {
        cache.add(options.cache, 'XCSRF', getHash({ jar }), opt.headers['x-csrf-token'])
      }
    }
    if (res.statusCode === 302 && !args.ignoreLoginError && res.headers.location && (res.headers.location.startsWith('https://www.roblox.com/newlogin') || res.headers.location.startsWith('/Login/Default.aspx'))) {
      throw new Error('You are not logged in')
    }
    return full ? res : res.body
  })
}