Core/CorsProxy.js

"use strict";

/*global require*/
var URI = require("urijs");

var defined = require("terriajs-cesium/Source/Core/defined").default;
var defaultValue = require("terriajs-cesium/Source/Core/defaultValue").default;
var loadJson = require("./loadJson");
var FeatureDetection = require("terriajs-cesium/Source/Core/FeatureDetection")
  .default;

var DEFAULT_BASE_PROXY_PATH = "proxy/";

/**
 * Rewrites URLs so that they're resolved via the TerriaJS-Server proxy rather than going direct. This is most useful
 * for getting around CORS restrictions on services that don't have CORS set up or when using pre-CORS browsers like IE9.
 * Going via the proxy is also useful if you want to change the caching headers on map requests (for instance many map
 * tile providers set cache headers to no-cache even for maps that rarely change, resulting in a much slower experience
 * particularly on time-series data).
 *
 * @param overrideLoadJson A method for getting JSON from a URL that matches the signature of Core/loadJson
 *      module - this is overridable mainly for testing.
 * @constructor
 */
function CorsProxy(overrideLoadJson) {
  this.loadJson = defaultValue(overrideLoadJson, loadJson);

  // Note that many of the following are intended to be set by a request to the server performed in {@link CorsProxy#init},
  // but these can be overridden if necessary.

  /**
   * The base URL of the TerriaJS server proxy, to which requests will be appended. In most cases this is the server's
   * host + '/proxy'.
   * @type {String}
   */
  this.baseProxyUrl = undefined;
  /**
   *  Domains that should be proxied for, as set by config files. Stored as an array of hosts - if a TLD is specified,
   * subdomains will also be proxied.
   *  @type {String[]}
   */
  this.proxyDomains = undefined;
  /**
   * True if we expect that the proxy will proxy any URL - note that if the server isn't set up to do this, having
   * this set to true will just result in a lot of failed AJAX calls
   * @type {boolean}
   */
  this.isOpenProxy = false;
  /**
   * Domains that are known to support CORS, as set by config files.
   * @type {String[]}
   */
  this.corsDomains = [];
  /**
   * Whether the proxy should be used regardless of whether the domain supports CORS or not. This defaults to true
   * on IE<10.
   * @type {boolean}
   */
  this.alwaysUseProxy =
    FeatureDetection.isInternetExplorer() &&
    FeatureDetection.internetExplorerVersion()[0] < 10; // IE versions prior to 10 don't support CORS, so always use the proxy.
  /**
   * Whether the page that Terria is running on is HTTPS. This is relevant because calling an HTTP domain from HTTPS
   * results in mixed content warnings and going through the proxy is required to get around this.
   * @type {boolean}
   */
  this.pageIsHttps =
    typeof window !== "undefined" &&
    defined(window.location) &&
    defined(window.location.href) &&
    new URI(window.location.href).protocol() === "https";
}

/**
 * Initialises values with config previously loaded from server. This is the recommended way to use this object as it ensures
 * the options will be correct for the proxy server it's configured to call, but this can be skipped and the values it
 * initialises set manually if desired.
 *
 * @param {Object} serverConfig Configuration options retrieved from a ServerConfig object.
 * @param {String} baseProxyUrl The base URL to proxy with - this will default to 'proxy/'
 * @param {String[]} proxyDomains Initial value for proxyDomains to which proxyable domains from the server will be appended -
 *      defaults to an empty array.
 * @returns {Promise} A promise that resolves when initialisation is complete.
 */
CorsProxy.prototype.init = function(serverConfig, baseProxyUrl, proxyDomains) {
  this.baseProxyUrl = defaultValue(baseProxyUrl, DEFAULT_BASE_PROXY_PATH);
  this.proxyDomains = defaultValue(proxyDomains, []);
  if (serverConfig && typeof serverConfig === "object") {
    this.isOpenProxy = !!serverConfig.proxyAllDomains;
    // ignore client list of allowed proxies in favour of definitive server list.
    if (Array.isArray(serverConfig.allowProxyFor)) {
      this.proxyDomains = serverConfig.allowProxyFor;
    }
  }
};

/**
 * Determines if the proxying service should be used to access the given URL, based on our list of
 * domains we're willing to proxy for and hosts that are known to support CORS.
 *
 * @param {String} url The url to examine.
 * @return {Boolean} true if the proxy should be used, false if not.
 */
CorsProxy.prototype.shouldUseProxy = function(url) {
  if (!defined(url)) {
    // eg. no url may be passed if all data is embedded
    return false;
  }

  var uri = new URI(url);
  var host = uri.host();

  if (host === "") {
    // do not proxy local files
    return false;
  }

  if (!this.isOpenProxy && !hostInDomains(host, this.proxyDomains)) {
    // we're not willing to proxy for this host
    return false;
  }
  if (this.alwaysUseProxy) {
    return true;
  }

  if (this.pageIsHttps && uri.protocol() === "http") {
    // if we're accessing an http resource from an https page, always proxy in order to avoid a mixed content error.
    return true;
  }

  if (hostInDomains(host, this.corsDomains)) {
    // we don't need to proxy for this host, because it supports CORS
    return false;
  }

  // we are ok with proxying for this host and we need to
  return true;
};

/**
 * Proxies a URL by appending it to {@link CorsProxy#baseProxyUrl}. Optionally inserts a proxyFlag that will override
 * the cache headers of the response, allowing for caching to be added where it wouldn't otherwise.
 *
 * @param {String} resource the URL to potentially proxy
 * @param {String} proxyFlag the proxy flag to pass - generally this is the length of time that you want to override
 *       the cache headers with. E.g. '2d' for 2 days.
 * @returns {String} The proxied URL
 */
CorsProxy.prototype.getURL = function(resource, proxyFlag) {
  var flag = proxyFlag === undefined ? "" : "_" + proxyFlag + "/";
  return this.baseProxyUrl + flag + resource;
};

/**
 * Convenience method that combines {@link CorsProxy#shouldUseProxy} and {@link getURL} - if the URL passed needs to be
 * proxied according to the rules/config of the proxy, this will return a proxied URL, otherwise it will return the
 * original URL.
 *
 * {@see CorsProxy#shouldUseProxy}
 * {@see CorsProxy#getURL}
 *
 * @param {String} resource the URL to potentially proxy
 * @param {String} proxyFlag the proxy flag to pass - generally this is the length of time that you want to override
 *       the cache headers with. E.g. '2d' for 2 days.
 * @returns {String} Either the URL passed in or a proxied URL if it should be proxied.
 */
CorsProxy.prototype.getURLProxyIfNecessary = function(resource, proxyFlag) {
  if (this.shouldUseProxy(resource)) {
    return this.getURL(resource, proxyFlag);
  }

  return resource;
};

/**
 * Determines whether this host is, or is a subdomain of, an item in the provided array.
 *
 * @param {String} host The host to search for
 * @param {String[]} domains The array of domains to look in
 * @returns {boolean} The result.
 */
function hostInDomains(host, domains) {
  if (!defined(domains)) {
    return false;
  }

  host = host.toLowerCase();
  for (var i = 0; i < domains.length; i++) {
    if (host.match("(^|\\.)" + domains[i] + "$")) {
      return true;
    }
  }
  return false;
}

module.exports = CorsProxy;