tor-browser

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

AdsFeed.sys.mjs (16368B)


      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  Utils: "resource://services-settings/Utils.sys.mjs",
      7 };
      8 
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  ContextId: "moz-src:///browser/modules/ContextId.sys.mjs",
     11  ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs",
     12  PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs",
     13 });
     14 
     15 import {
     16  actionTypes as at,
     17  actionCreators as ac,
     18 } from "resource://newtab/common/Actions.mjs";
     19 
     20 // Prefs for AdsFeeds to run
     21 const PREF_UNIFIED_ADS_ADSFEED_ENABLED = "unifiedAds.adsFeed.enabled";
     22 const PREF_UNIFIED_ADS_SPOCS_ENABLED = "unifiedAds.spocs.enabled";
     23 const PREF_UNIFIED_ADS_TILES_ENABLED = "unifiedAds.tiles.enabled";
     24 
     25 // Prefs for UAPI
     26 const PREF_UNIFIED_ADS_BLOCKED_LIST = "unifiedAds.blockedAds";
     27 const PREF_UNIFIED_ADS_ENDPOINT = "unifiedAds.endpoint";
     28 
     29 // Prefs for Tiles
     30 const PREF_TILES_COUNTS = "discoverystream.placements.tiles.counts";
     31 const PREF_TILES_PLACEMENTS = "discoverystream.placements.tiles";
     32 
     33 // Prefs for Sponsored Content
     34 const PREF_SPOC_COUNTS = "discoverystream.placements.spocs.counts";
     35 const PREF_SPOC_PLACEMENTS = "discoverystream.placements.spocs";
     36 
     37 // Primary pref that is toggled when enabling top site sponsored tiles
     38 const PREF_FEED_TOPSITES = "feeds.topsites";
     39 const PREF_SYSTEM_TOPSITES = "feeds.system.topsites";
     40 const PREF_SHOW_SPONSORED_TOPSITES = "showSponsoredTopSites";
     41 
     42 // Primary pref that is toggled when enabling sponsored stories
     43 const PREF_FEED_SECTION_TOPSTORIES = "feeds.section.topstories";
     44 const PREF_SYSTEM_TOPSTORIES = "feeds.system.topstories";
     45 const PREF_SHOW_SPONSORED = "showSponsored";
     46 const PREF_SYSTEM_SHOW_SPONSORED = "system.showSponsored";
     47 
     48 const CACHE_KEY = "ads_feed";
     49 const ADS_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
     50 
     51 export class AdsFeed {
     52  constructor() {
     53    this.enabled = false;
     54    this.loaded = false;
     55    this.lastUpdated = null;
     56    this.tiles = [];
     57    this.spocs = [];
     58    this.spocPlacements = [];
     59    this.cache = this.PersistentCache(CACHE_KEY, true);
     60  }
     61 
     62  async _resetCache() {
     63    if (this.cache) {
     64      await this.cache.set("ads", {});
     65    }
     66  }
     67 
     68  async resetAdsFeed() {
     69    await this._resetCache();
     70    this.tiles = [];
     71    this.spocs = [];
     72    this.spocPlacements = [];
     73    this.lastUpdated = null;
     74    this.loaded = false;
     75    this.enabled = false;
     76 
     77    this.store.dispatch(
     78      ac.OnlyToMain({
     79        type: at.ADS_RESET,
     80      })
     81    );
     82  }
     83 
     84  async deleteUserAdsData() {
     85    const state = this.store.getState();
     86    const headers = new Headers();
     87    const endpointBaseUrl = state.Prefs.values[PREF_UNIFIED_ADS_ENDPOINT];
     88 
     89    if (!endpointBaseUrl) {
     90      return;
     91    }
     92 
     93    const endpoint = `${endpointBaseUrl}v1/delete_user`;
     94    const body = {
     95      context_id: await lazy.ContextId.request(),
     96    };
     97 
     98    headers.append("content-type", "application/json");
     99 
    100    await this.fetch(endpoint, {
    101      method: "DELETE",
    102      headers,
    103      body: JSON.stringify(body),
    104    });
    105  }
    106 
    107  isAdsFeedEnabled() {
    108    // Check if AdsFeed is enabled
    109    return this.store.getState().Prefs.values[PREF_UNIFIED_ADS_ADSFEED_ENABLED];
    110  }
    111 
    112  isEnabled() {
    113    this.loaded = true;
    114 
    115    // Check if AdsFeed is enabled
    116    const adsFeedEnabled = this.isAdsFeedEnabled();
    117 
    118    if (!adsFeedEnabled) {
    119      // Exit early as AdsFeed is turned off and shouldn't do anything
    120      return false;
    121    }
    122 
    123    // Check all known prefs that top site tiles are enabled
    124    const tilesEnabled =
    125      this.store.getState().Prefs.values[PREF_FEED_TOPSITES] &&
    126      this.store.getState().Prefs.values[PREF_SYSTEM_TOPSITES];
    127 
    128    const sponsoredTilesEnabled =
    129      this.store.getState().Prefs.values[PREF_SHOW_SPONSORED_TOPSITES] &&
    130      this.store.getState().Prefs.values[PREF_UNIFIED_ADS_TILES_ENABLED];
    131 
    132    // Check all known prefs that spocs are enabled
    133    const sponsoredStoriesEnabled =
    134      this.store.getState().Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED] &&
    135      this.store.getState().Prefs.values[PREF_SYSTEM_SHOW_SPONSORED] &&
    136      this.store.getState().Prefs.values[PREF_SHOW_SPONSORED];
    137 
    138    const storiesEnabled =
    139      this.store.getState().Prefs.values[PREF_FEED_SECTION_TOPSTORIES] &&
    140      this.store.getState().Prefs.values[PREF_SYSTEM_TOPSTORIES];
    141 
    142    // Confirm at least one ads section (tiles, spocs) are enabled to enable the AdsFeed
    143    if (
    144      (tilesEnabled && sponsoredTilesEnabled) ||
    145      (storiesEnabled && sponsoredStoriesEnabled)
    146    ) {
    147      if (adsFeedEnabled) {
    148        this.enabled = true;
    149      }
    150 
    151      return adsFeedEnabled;
    152    }
    153 
    154    // If the AdsFeed is enabled but no placements are enabled, delete user ads data
    155    this.deleteUserAdsData();
    156 
    157    return false;
    158  }
    159 
    160  /**
    161   * This thin wrapper around global.fetch makes it easier for us to write
    162   * automated tests that simulate responses from this fetch.
    163   */
    164  fetch(...args) {
    165    return fetch(...args);
    166  }
    167 
    168  /**
    169   * Normalize new Unified Ads API response into
    170   * previous Contile ads response
    171   *
    172   * @param {Array} - Array of UAPI placement objects ("newtab_tile_1", etc.)
    173   * @returns {object} - Object containing array of formatted UAPI objects to match legacy Contile system
    174   */
    175  _normalizeTileData(data) {
    176    const formattedTileDataArray = [];
    177    const responseTilesData = Object.values(data);
    178 
    179    // Bug 1930653: Confirm response has data before iterating
    180    if (responseTilesData?.length) {
    181      for (const tileData of responseTilesData) {
    182        const [tile] = tileData;
    183 
    184        const formattedData = {
    185          id: tile.block_key,
    186          block_key: tile.block_key,
    187          name: tile.name,
    188          url: tile.url,
    189          click_url: tile.callbacks.click,
    190          image_url: tile.image_url,
    191          impression_url: tile.callbacks.impression,
    192          image_size: 200,
    193        };
    194 
    195        formattedTileDataArray.push(formattedData);
    196      }
    197    }
    198 
    199    return { tiles: formattedTileDataArray };
    200  }
    201 
    202  /**
    203   * Return object of supported ad types to query from MARS API from the AdsFeed file
    204   *
    205   * @returns {object}
    206   */
    207  getSupportedAdTypes() {
    208    const supportsAdsFeedTiles =
    209      this.store.getState().Prefs.values[PREF_UNIFIED_ADS_TILES_ENABLED];
    210 
    211    const supportsAdsFeedSpocs =
    212      this.store.getState().Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED];
    213 
    214    return {
    215      tiles: supportsAdsFeedTiles,
    216      spocs: supportsAdsFeedSpocs,
    217    };
    218  }
    219 
    220  /**
    221   * Get ads data either from cache or from API and
    222   * broadcast the data via at.ADS_UPDATE_{DATA_TYPE} event
    223   *
    224   * @param {boolean} isStartup=false - This is only used for reporting
    225   * and is passed to the update functions meta attribute
    226   * @returns {void}
    227   */
    228  async getAdsData(isStartup = false) {
    229    const supportedAdTypes = this.getSupportedAdTypes();
    230    const cachedData = (await this.cache.get()) || {};
    231 
    232    const { ads } = cachedData;
    233    const adsCacheValid = ads
    234      ? this.Date().now() - ads.lastUpdated < ADS_UPDATE_TIME
    235      : false;
    236 
    237    let data = null;
    238 
    239    // Get new data if necessary or default to cache
    240    if (!ads?.lastUpdated || !adsCacheValid) {
    241      // Fetch new data
    242      data = await this.fetchData(supportedAdTypes);
    243      data.lastUpdated = this.Date().now();
    244    } else {
    245      // Use cached data
    246      data = ads;
    247    }
    248 
    249    if (!data) {
    250      throw new Error(`No data available`);
    251    }
    252 
    253    // Update tile information if tile data is supported
    254    if (supportedAdTypes.tiles) {
    255      this.tiles = data.tiles;
    256    }
    257 
    258    // Update tile information if spoc data is supported
    259    if (supportedAdTypes.spocs) {
    260      this.spocs = data.spocs;
    261      // DSFeed uses unifiedAdsPlacements to determine which spocs to fetch/place into the feed.
    262      this.spocPlacements = data.spocPlacements;
    263    }
    264 
    265    this.lastUpdated = data.lastUpdated;
    266    await this.update(isStartup);
    267  }
    268 
    269  /**
    270   * Fetch data from the Mozilla Ad Routing Service (MARS) unified ads API
    271   * This function is designed to get whichever ads types are needed (tiles, spocs)
    272   *
    273   * @param {Array} supportedAdTypes
    274   * @returns {object} Response object containing ad information from MARS
    275   */
    276  async fetchData(supportedAdTypes) {
    277    const state = this.store.getState();
    278    const headers = new Headers();
    279    headers.append("content-type", "application/json");
    280 
    281    const endpointBaseUrl = state.Prefs.values[PREF_UNIFIED_ADS_ENDPOINT];
    282    const marsOhttpEnabled = Services.prefs.getBoolPref(
    283      "browser.newtabpage.activity-stream.unifiedAds.ohttp.enabled",
    284      false
    285    );
    286    const ohttpRelayURL = Services.prefs.getStringPref(
    287      "browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL",
    288      ""
    289    );
    290    const ohttpConfigURL = Services.prefs.getStringPref(
    291      "browser.newtabpage.activity-stream.discoverystream.ohttp.configURL",
    292      ""
    293    );
    294 
    295    let blockedSponsors =
    296      this.store.getState().Prefs.values[PREF_UNIFIED_ADS_BLOCKED_LIST];
    297 
    298    // Overwrite URL to Unified Ads endpoint
    299    const fetchUrl = `${endpointBaseUrl}v1/ads`;
    300 
    301    // Can include both tile and spoc placement data
    302    let placements = [];
    303    let responseData;
    304    let returnData = {};
    305 
    306    // Determine which data needs to be fetched
    307    if (supportedAdTypes.tiles) {
    308      const tilesPlacementsArray = state.Prefs.values[
    309        PREF_TILES_PLACEMENTS
    310      ]?.split(`,`)
    311        .map(s => s.trim())
    312        .filter(item => item);
    313      const tilesCountsArray = state.Prefs.values[PREF_TILES_COUNTS]?.split(`,`)
    314        .map(s => s.trim())
    315        .filter(item => item)
    316        .map(item => parseInt(item, 10));
    317 
    318      const tilesPlacements = tilesPlacementsArray.map((placement, index) => ({
    319        placement,
    320        count: tilesCountsArray[index],
    321      }));
    322 
    323      placements.push(...tilesPlacements);
    324    }
    325 
    326    // Determine which data needs to be fetched
    327    if (supportedAdTypes.spocs) {
    328      const spocPlacementsArray = state.Prefs.values[
    329        PREF_SPOC_PLACEMENTS
    330      ]?.split(`,`)
    331        .map(s => s.trim())
    332        .filter(item => item);
    333 
    334      const spocCountsArray = state.Prefs.values[PREF_SPOC_COUNTS]?.split(`,`)
    335        .map(s => s.trim())
    336        .filter(item => item)
    337        .map(item => parseInt(item, 10));
    338 
    339      const spocPlacements = spocPlacementsArray.map((placement, index) => ({
    340        placement,
    341        count: spocCountsArray[index],
    342      }));
    343 
    344      returnData.spocPlacements = spocPlacements;
    345 
    346      placements.push(...spocPlacements);
    347    }
    348 
    349    let fetchPromise;
    350 
    351    const controller = new AbortController();
    352    const { signal } = controller;
    353 
    354    const options = {
    355      method: "POST",
    356      headers,
    357      body: JSON.stringify({
    358        context_id: await lazy.ContextId.request(),
    359        placements,
    360        blocks: blockedSponsors.split(","),
    361      }),
    362      credentials: "omit",
    363      signal,
    364    };
    365 
    366    // Make Oblivious Fetch Request
    367    if (marsOhttpEnabled && ohttpConfigURL && ohttpRelayURL) {
    368      const config = await lazy.ObliviousHTTP.getOHTTPConfig(ohttpConfigURL);
    369      if (!config) {
    370        console.error(
    371          new Error(
    372            `OHTTP was configured for ${fetchUrl} but we couldn't fetch a valid config`
    373          )
    374        );
    375        return null;
    376      }
    377      fetchPromise = lazy.ObliviousHTTP.ohttpRequest(
    378        ohttpRelayURL,
    379        config,
    380        fetchUrl,
    381        options
    382      );
    383    } else {
    384      fetchPromise = this.fetch(fetchUrl, options);
    385    }
    386 
    387    const response = await fetchPromise;
    388 
    389    if (response && response.status === 200) {
    390      responseData = await response.json();
    391    } else {
    392      throw new Error(
    393        `Error fetching data: ${response.status} - ${response.statusText}`
    394      );
    395    }
    396 
    397    if (supportedAdTypes.tiles) {
    398      const filteredRespDataTiles = Object.keys(responseData)
    399        .filter(key => key.startsWith("newtab_tile_"))
    400        .reduce((acc, key) => {
    401          acc[key] = responseData[key];
    402          return acc;
    403        }, {});
    404 
    405      const normalizedTileData = this._normalizeTileData(filteredRespDataTiles);
    406      returnData.tiles = normalizedTileData.tiles;
    407    }
    408 
    409    if (supportedAdTypes.spocs) {
    410      const filteredRespDataNonTiles = Object.keys(responseData)
    411        .filter(key => !key.startsWith("newtab_tile_"))
    412        .reduce((acc, key) => {
    413          acc[key] = responseData[key];
    414          return acc;
    415        }, {});
    416 
    417      returnData.spocs = filteredRespDataNonTiles.newtab_spocs;
    418    }
    419 
    420    return returnData;
    421  }
    422 
    423  /**
    424   * Init function that runs only from onAction at.INIT call.
    425   *
    426   * @param {boolean} isStartup=false
    427   * @returns {void}
    428   */
    429  async init(isStartup = false) {
    430    if (this.isEnabled()) {
    431      await this.getAdsData(isStartup);
    432    }
    433  }
    434 
    435  /**
    436   * Sets cached data and dispatches at.ADS_UPDATE_{DATA_TYPE} event to update store with new ads data
    437   *
    438   * @param {boolean} isStartup
    439   * @returns {void}
    440   */
    441  async update(isStartup) {
    442    await this.cache.set("ads", {
    443      ...(this.tiles ? { tiles: this.tiles } : {}),
    444      ...(this.spocs ? { spocs: this.spocs } : {}),
    445      ...(this.spocPlacements ? { spocPlacements: this.spocPlacements } : {}),
    446      lastUpdated: this.lastUpdated,
    447    });
    448 
    449    if (this.tiles && this.tiles.length) {
    450      this.store.dispatch(
    451        ac.BroadcastToContent({
    452          type: at.ADS_UPDATE_TILES,
    453          data: {
    454            tiles: this.tiles,
    455          },
    456          meta: {
    457            isStartup,
    458          },
    459        })
    460      );
    461    }
    462 
    463    if (this.spocs && this.spocs.length) {
    464      this.store.dispatch(
    465        ac.BroadcastToContent({
    466          type: at.ADS_UPDATE_SPOCS,
    467          data: {
    468            spocs: this.spocs,
    469            spocPlacements: this.spocPlacements,
    470          },
    471          meta: {
    472            isStartup,
    473          },
    474        })
    475      );
    476    }
    477  }
    478 
    479  async onPrefChangedAction(action) {
    480    switch (action.data.name) {
    481      // AdsFeed Feature Prefs
    482      // Shortcuts or Stories Enabled/Disabled
    483      case PREF_UNIFIED_ADS_TILES_ENABLED:
    484      case PREF_UNIFIED_ADS_SPOCS_ENABLED:
    485      case PREF_UNIFIED_ADS_ADSFEED_ENABLED:
    486      case PREF_FEED_TOPSITES:
    487      case PREF_SYSTEM_TOPSITES:
    488      case PREF_SYSTEM_TOPSTORIES:
    489      case PREF_FEED_SECTION_TOPSTORIES:
    490        if (!this.isAdsFeedEnabled()) {
    491          // Only act on these prefs if AdsFeed is enabled
    492          break;
    493        }
    494 
    495        // TODO: Should we use the value of these prefs to determine what to do?
    496        if (this.isEnabled()) {
    497          await this.getAdsData(false);
    498        } else {
    499          await this.deleteUserAdsData();
    500          await this.resetAdsFeed();
    501        }
    502        break;
    503      case PREF_SHOW_SPONSORED_TOPSITES:
    504      case PREF_SHOW_SPONSORED:
    505      case PREF_SYSTEM_SHOW_SPONSORED:
    506        if (!this.isEnabled()) {
    507          // Only act on these prefs if AdsFeed is enabled
    508          break;
    509        }
    510 
    511        if (action.data.value) {
    512          await this.getAdsData(false);
    513        } else {
    514          await this.deleteUserAdsData();
    515          await this.resetAdsFeed();
    516        }
    517 
    518        break;
    519    }
    520  }
    521 
    522  async onAction(action) {
    523    switch (action.type) {
    524      case at.INIT:
    525        await this.init(true /* isStartup */);
    526        break;
    527      case at.UNINIT:
    528        break;
    529      case at.DISCOVERY_STREAM_DEV_SYSTEM_TICK:
    530      case at.SYSTEM_TICK:
    531        if (this.isEnabled()) {
    532          await this.getAdsData(false);
    533        }
    534        break;
    535 
    536      case at.PREF_CHANGED:
    537        await this.onPrefChangedAction(action);
    538        break;
    539      case at.DISCOVERY_STREAM_CONFIG_CHANGE: // Event emitted from ASDevTools "Reset Cache" button
    540      case at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE: // Event emitted from ASDevTools "Expire Cache" button
    541        // Clear cache
    542        await this.resetAdsFeed();
    543 
    544        // Get new ads
    545        if (this.isEnabled()) {
    546          await this.getAdsData(false);
    547        }
    548        break;
    549    }
    550  }
    551 }
    552 
    553 /**
    554 * Creating a thin wrapper around PersistentCache, ObliviousHTTP and Date.
    555 * This makes it easier for us to write automated tests that simulate responses.
    556 */
    557 
    558 AdsFeed.prototype.PersistentCache = (...args) => {
    559  return new lazy.PersistentCache(...args);
    560 };
    561 
    562 AdsFeed.prototype.Date = () => {
    563  return Date;
    564 };
    565 
    566 AdsFeed.prototype.ObliviousHTTP = (...args) => {
    567  return lazy.ObliviousHTTP(...args);
    568 };