tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

GeolocationUtils.sys.mjs (10443B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 const lazy = {};
      6 
      7 ChromeUtils.defineESModuleGetters(lazy, {
      8  MerinoClient: "moz-src:///browser/components/urlbar/MerinoClient.sys.mjs",
      9  UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
     10 });
     11 
     12 ChromeUtils.defineLazyGetter(lazy, "logger", () =>
     13  lazy.UrlbarUtils.getLogger({ prefix: "GeolocationUtils" })
     14 );
     15 
     16 // Cache period for Merino's geolocation response. This is intentionally a small
     17 // amount of time. See the `cachePeriodMs` discussion in `MerinoClient`.
     18 const GEOLOCATION_CACHE_PERIOD_MS = 2 * 60 * 60 * 1000; // 2 hours.
     19 
     20 // The mean Earth radius used in distance calculations.
     21 const EARTH_RADIUS_KM = 6371.009;
     22 
     23 // Timeout setting to fetch geolocation from Merino.
     24 const MERINO_TIMEOUT_MS = 5000;
     25 
     26 /**
     27 * Utils for fetching the client's geolocation from Merino, computing distances
     28 * between locations, and finding suggestions that best match the geolocation.
     29 */
     30 class _GeolocationUtils {
     31  /**
     32   * Fetches the client's geolocation from Merino. Merino gets the geolocation
     33   * by looking up the client's IP address in its MaxMind database. We cache
     34   * responses for a brief period of time so that fetches during a urlbar
     35   * session don't ping Merino over and over.
     36   *
     37   * @returns {Promise<object>}
     38   *   An object with the following properties (see Merino source for latest):
     39   *
     40   *   {string} country
     41   *     The full country name.
     42   *   {string} country_code
     43   *     The country ISO code.
     44   *   {string} region
     45   *     The full region name, e.g., the full name of a U.S. state.
     46   *   {string} region_code
     47   *     The region ISO code, e.g., the two-letter abbreviation for U.S. states.
     48   *   {string} city
     49   *     The city name.
     50   *   {object} location
     51   *     This object has the following properties:
     52   *     {number} latitude
     53   *       Latitude in decimal degrees.
     54   *     {number} longitude
     55   *       Longitude in decimal degrees.
     56   *     {number} radius
     57   *       Accuracy radius in km.
     58   */
     59  async geolocation() {
     60    if (!this.#merino) {
     61      this.#merino = new lazy.MerinoClient("GeolocationUtils", {
     62        cachePeriodMs: GEOLOCATION_CACHE_PERIOD_MS,
     63      });
     64    }
     65 
     66    lazy.logger.debug("Fetching geolocation from Merino");
     67    let results = await this.#merino.fetch({
     68      providers: ["geolocation"],
     69      query: "",
     70      timeoutMs: MERINO_TIMEOUT_MS,
     71    });
     72 
     73    lazy.logger.debug("Got geolocation from Merino", results);
     74 
     75    return results?.[0]?.custom_details?.geolocation || null;
     76  }
     77 
     78  /**
     79   * @typedef {object} GeoLocationItem
     80   * @property {string|number} [latitude]
     81   *   The location's latitude in decimal coordinates as either a string or float.
     82   * @property {string|number} [longitude]
     83   *   The location's longitude in decimal coordinates as either a string or float.
     84   * @property {string} [country]
     85   *   The location's two-digit ISO country code. Case doesn't matter.
     86   * @property {string} [region]
     87   *   The location's region, e.g., a U.S. state. This is compared to the
     88   *   `region_code` in the Merino geolocation response (case insensitive) so
     89   *   it should be the same format: the region ISO code, e.g., the two-letter
     90   *   abbreviation for U.S. states.
     91   * @property {number} [population]
     92   *   The location's population.
     93   */
     94 
     95  /**
     96   * Returns the item from an array of candidate items that best matches the
     97   * client's geolocation. For urlbar, typically the items are suggestions, but
     98   * they can be anything.
     99   *
    100   * The best item is determined as follows:
    101   *
    102   * 1. If any item locations include geographic coordinates, then the item with
    103   *    the closest location to the client's geolocation will be returned.
    104   * 2. If any item locations include regions and populations, then the item
    105   *    with the most populous location in the client's region will be returned.
    106   * 3. If any item locations include regions, then the first item with a
    107   *    location in the client's region will be returned.
    108   * 4. If any item locations include countries and populations, then the item
    109   *    with the most populous location in the client's country will be
    110   *    returned.
    111   * 5. If any item locations include countries, then the first item with the
    112   *    same country as the client will be returned.
    113   *
    114   * @param {Array} items
    115   *   Array of items, which can be anything.
    116   * @param {(item: any) => GeoLocationItem} locationFromItem
    117   *   A function that maps an item to its location. It will be called as
    118   *   `locationFromItem(item)` and it should return an object with the
    119   *   defined properties, all optional.
    120   * @returns {Promise<object|null>}
    121   *   The best item as described above, or null if `items` is empty.
    122   */
    123  async best(items, locationFromItem = i => i) {
    124    if (items.length <= 1) {
    125      return items[0] || null;
    126    }
    127 
    128    let geo = await this.geolocation();
    129    if (!geo) {
    130      return items[0];
    131    }
    132    return (
    133      this.#bestByDistance(geo, items, locationFromItem) ||
    134      this.#bestByRegion(geo, items, locationFromItem) ||
    135      items[0]
    136    );
    137  }
    138 
    139  /**
    140   * Returns the item with the city nearest the client's geolocation based on
    141   * the great-circle distance between the coordinates [1]. This isn't
    142   * necessarily super accurate, but that's OK since it's stable and accurate
    143   * enough to find a good matching item.
    144   *
    145   * [1] https://en.wikipedia.org/wiki/Great-circle_distance
    146   *
    147   * @param {object} geo
    148   *   The `geolocation` object returned by Merino's geolocation provider. It's
    149   *   expected to have at least the properties below, but we gracefully handle
    150   *   exceptions. The coordinates are expected to be in decimal and the radius
    151   *   is expected to be in km.
    152   *
    153   *   ```
    154   *   { location: { latitude, longitude, radius }}
    155   *   ```
    156   * @param {Array} items
    157   *   Array of items as described in the doc for `best()`.
    158   * @param {(item: any) => GeoLocationItem} locationFromItem
    159   *   Mapping function as described in the doc for `best()`.
    160   * @returns {object|null}
    161   *   The nearest item as described above. If there are multiple nearest items
    162   *   within the accuracy radius, the most populous one is returned. If the
    163   *   `geo` does not include a location or coordinates, null is returned.
    164   */
    165  #bestByDistance(geo, items, locationFromItem) {
    166    let geoLat = parseFloat(geo.location?.latitude);
    167    let geoLong = parseFloat(geo.location?.longitude);
    168    if (isNaN(geoLat) || isNaN(geoLong)) {
    169      return null;
    170    }
    171 
    172    // All distances are in km.
    173    [geoLat, geoLong] = [geoLat, geoLong].map(toRadians);
    174    let geoLatSin = Math.sin(geoLat);
    175    let geoLatCos = Math.cos(geoLat);
    176    let geoRadius = geo.location?.radius || 5;
    177 
    178    let bestTuple;
    179    let dMin = Infinity;
    180    for (let item of items) {
    181      let location = locationFromItem(item);
    182      if (!location) {
    183        continue;
    184      }
    185 
    186      let locationLat =
    187        typeof location.latitude == "number"
    188          ? location.latitude
    189          : parseFloat(location.latitude);
    190      let locationLong =
    191        typeof location.longitude == "number"
    192          ? location.longitude
    193          : parseFloat(location.longitude);
    194      if (isNaN(locationLat) || isNaN(locationLong)) {
    195        continue;
    196      }
    197 
    198      let [itemLat, itemLong] = [locationLat, locationLong].map(toRadians);
    199      let d =
    200        EARTH_RADIUS_KM *
    201        Math.acos(
    202          geoLatSin * Math.sin(itemLat) +
    203            geoLatCos *
    204              Math.cos(itemLat) *
    205              Math.cos(Math.abs(geoLong - itemLong))
    206        );
    207      if (
    208        !bestTuple ||
    209        // The item's location is closer to the client than the best
    210        // location.
    211        d + geoRadius < dMin ||
    212        // The item's location is the same distance from the client as the
    213        // best location, i.e., the difference between the two distances is
    214        // within the accuracy radius. Choose the item if it has a larger
    215        // population.
    216        (Math.abs(d - dMin) <= geoRadius &&
    217          hasLargerPopulation(location, bestTuple.location))
    218      ) {
    219        dMin = d;
    220        bestTuple = { item, location };
    221      }
    222    }
    223 
    224    return bestTuple?.item || null;
    225  }
    226 
    227  /**
    228   * Returns the first item with a city located in the same region and country
    229   * as the client's geolocation. If there is no such item, the first item in
    230   * the same country is returned. If there is no item in the same country, null
    231   * is returned. Ties are broken by population.
    232   *
    233   * @param {object} geo
    234   *   The `geolocation` object returned by Merino's geolocation provider. It's
    235   *   expected to have at least the properties listed below, but we gracefully
    236   *   handle exceptions:
    237   *
    238   *   ```
    239   *   { region_code, country_code }
    240   *   ```
    241   * @param {Array} items
    242   *   Array of items as described in the doc for `best()`.
    243   * @param {(item: any) => GeoLocationItem} locationFromItem
    244   *   Mapping function as described in the doc for `best()`.
    245   * @returns {object|null}
    246   *   The item as described above or null.
    247   */
    248  #bestByRegion(geo, items, locationFromItem) {
    249    let geoCountry = geo.country_code?.toLowerCase();
    250    if (!geoCountry) {
    251      return null;
    252    }
    253 
    254    let geoRegion = geo.region_code?.toLowerCase();
    255 
    256    let bestCountryTuple;
    257    let bestRegionTuple;
    258    for (let item of items) {
    259      let location = locationFromItem(item);
    260      if (location?.country?.toLowerCase() == geoCountry) {
    261        if (
    262          !bestCountryTuple ||
    263          hasLargerPopulation(location, bestCountryTuple.location)
    264        ) {
    265          bestCountryTuple = { item, location };
    266        }
    267        if (
    268          location.region?.toLowerCase() == geoRegion &&
    269          (!bestRegionTuple ||
    270            hasLargerPopulation(location, bestRegionTuple.location))
    271        ) {
    272          bestRegionTuple = { item, location };
    273        }
    274      }
    275    }
    276 
    277    return bestRegionTuple?.item || bestCountryTuple?.item || null;
    278  }
    279 
    280  // `MerinoClient`
    281  #merino;
    282 }
    283 
    284 function toRadians(deg) {
    285  return (deg * Math.PI) / 180;
    286 }
    287 
    288 function hasLargerPopulation(a, b) {
    289  return (
    290    typeof a.population == "number" &&
    291    (typeof b.population != "number" || b.population < a.population)
    292  );
    293 }
    294 
    295 export const GeolocationUtils = new _GeolocationUtils();