/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const lazy = { Utils: "resource://services-settings/Utils.sys.mjs", }; ChromeUtils.defineESModuleGetters(lazy, { ContextId: "moz-src:///browser/modules/ContextId.sys.mjs", ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs", PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs", }); import { actionTypes as at, actionCreators as ac, } from "resource://newtab/common/Actions.mjs"; // Prefs for AdsFeeds to run const PREF_UNIFIED_ADS_ADSFEED_ENABLED = "unifiedAds.adsFeed.enabled"; const PREF_UNIFIED_ADS_SPOCS_ENABLED = "unifiedAds.spocs.enabled"; const PREF_UNIFIED_ADS_TILES_ENABLED = "unifiedAds.tiles.enabled"; // Prefs for UAPI const PREF_UNIFIED_ADS_BLOCKED_LIST = "unifiedAds.blockedAds"; const PREF_UNIFIED_ADS_ENDPOINT = "unifiedAds.endpoint"; // Prefs for Tiles const PREF_TILES_COUNTS = "discoverystream.placements.tiles.counts"; const PREF_TILES_PLACEMENTS = "discoverystream.placements.tiles"; // Prefs for Sponsored Content const PREF_SPOC_COUNTS = "discoverystream.placements.spocs.counts"; const PREF_SPOC_PLACEMENTS = "discoverystream.placements.spocs"; // Primary pref that is toggled when enabling top site sponsored tiles const PREF_FEED_TOPSITES = "feeds.topsites"; const PREF_SYSTEM_TOPSITES = "feeds.system.topsites"; const PREF_SHOW_SPONSORED_TOPSITES = "showSponsoredTopSites"; // Primary pref that is toggled when enabling sponsored stories const PREF_FEED_SECTION_TOPSTORIES = "feeds.section.topstories"; const PREF_SYSTEM_TOPSTORIES = "feeds.system.topstories"; const PREF_SHOW_SPONSORED = "showSponsored"; const PREF_SYSTEM_SHOW_SPONSORED = "system.showSponsored"; const CACHE_KEY = "ads_feed"; const ADS_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes export class AdsFeed { constructor() { this.enabled = false; this.loaded = false; this.lastUpdated = null; this.tiles = []; this.spocs = []; this.spocPlacements = []; this.cache = this.PersistentCache(CACHE_KEY, true); } async _resetCache() { if (this.cache) { await this.cache.set("ads", {}); } } async resetAdsFeed() { await this._resetCache(); this.tiles = []; this.spocs = []; this.spocPlacements = []; this.lastUpdated = null; this.loaded = false; this.enabled = false; this.store.dispatch( ac.OnlyToMain({ type: at.ADS_RESET, }) ); } async deleteUserAdsData() { const state = this.store.getState(); const headers = new Headers(); const endpointBaseUrl = state.Prefs.values[PREF_UNIFIED_ADS_ENDPOINT]; if (!endpointBaseUrl) { return; } const endpoint = `${endpointBaseUrl}v1/delete_user`; const body = { context_id: await lazy.ContextId.request(), }; headers.append("content-type", "application/json"); await this.fetch(endpoint, { method: "DELETE", headers, body: JSON.stringify(body), }); } isAdsFeedEnabled() { // Check if AdsFeed is enabled return this.store.getState().Prefs.values[PREF_UNIFIED_ADS_ADSFEED_ENABLED]; } isEnabled() { this.loaded = true; // Check if AdsFeed is enabled const adsFeedEnabled = this.isAdsFeedEnabled(); if (!adsFeedEnabled) { // Exit early as AdsFeed is turned off and shouldn't do anything return false; } // Check all known prefs that top site tiles are enabled const tilesEnabled = this.store.getState().Prefs.values[PREF_FEED_TOPSITES] && this.store.getState().Prefs.values[PREF_SYSTEM_TOPSITES]; const sponsoredTilesEnabled = this.store.getState().Prefs.values[PREF_SHOW_SPONSORED_TOPSITES] && this.store.getState().Prefs.values[PREF_UNIFIED_ADS_TILES_ENABLED]; // Check all known prefs that spocs are enabled const sponsoredStoriesEnabled = this.store.getState().Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED] && this.store.getState().Prefs.values[PREF_SYSTEM_SHOW_SPONSORED] && this.store.getState().Prefs.values[PREF_SHOW_SPONSORED]; const storiesEnabled = this.store.getState().Prefs.values[PREF_FEED_SECTION_TOPSTORIES] && this.store.getState().Prefs.values[PREF_SYSTEM_TOPSTORIES]; // Confirm at least one ads section (tiles, spocs) are enabled to enable the AdsFeed if ( (tilesEnabled && sponsoredTilesEnabled) || (storiesEnabled && sponsoredStoriesEnabled) ) { if (adsFeedEnabled) { this.enabled = true; } return adsFeedEnabled; } // If the AdsFeed is enabled but no placements are enabled, delete user ads data this.deleteUserAdsData(); return false; } /** * This thin wrapper around global.fetch makes it easier for us to write * automated tests that simulate responses from this fetch. */ fetch(...args) { return fetch(...args); } /** * Normalize new Unified Ads API response into * previous Contile ads response * * @param {Array} - Array of UAPI placement objects ("newtab_tile_1", etc.) * @returns {object} - Object containing array of formatted UAPI objects to match legacy Contile system */ _normalizeTileData(data) { const formattedTileDataArray = []; const responseTilesData = Object.values(data); // Bug 1930653: Confirm response has data before iterating if (responseTilesData?.length) { for (const tileData of responseTilesData) { const [tile] = tileData; const formattedData = { id: tile.block_key, block_key: tile.block_key, name: tile.name, url: tile.url, click_url: tile.callbacks.click, image_url: tile.image_url, impression_url: tile.callbacks.impression, image_size: 200, }; formattedTileDataArray.push(formattedData); } } return { tiles: formattedTileDataArray }; } /** * Return object of supported ad types to query from MARS API from the AdsFeed file * * @returns {object} */ getSupportedAdTypes() { const supportsAdsFeedTiles = this.store.getState().Prefs.values[PREF_UNIFIED_ADS_TILES_ENABLED]; const supportsAdsFeedSpocs = this.store.getState().Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED]; return { tiles: supportsAdsFeedTiles, spocs: supportsAdsFeedSpocs, }; } /** * Get ads data either from cache or from API and * broadcast the data via at.ADS_UPDATE_{DATA_TYPE} event * * @param {boolean} isStartup=false - This is only used for reporting * and is passed to the update functions meta attribute * @returns {void} */ async getAdsData(isStartup = false) { const supportedAdTypes = this.getSupportedAdTypes(); const cachedData = (await this.cache.get()) || {}; const { ads } = cachedData; const adsCacheValid = ads ? this.Date().now() - ads.lastUpdated < ADS_UPDATE_TIME : false; let data = null; // Get new data if necessary or default to cache if (!ads?.lastUpdated || !adsCacheValid) { // Fetch new data data = await this.fetchData(supportedAdTypes); data.lastUpdated = this.Date().now(); } else { // Use cached data data = ads; } if (!data) { throw new Error(`No data available`); } // Update tile information if tile data is supported if (supportedAdTypes.tiles) { this.tiles = data.tiles; } // Update tile information if spoc data is supported if (supportedAdTypes.spocs) { this.spocs = data.spocs; // DSFeed uses unifiedAdsPlacements to determine which spocs to fetch/place into the feed. this.spocPlacements = data.spocPlacements; } this.lastUpdated = data.lastUpdated; await this.update(isStartup); } /** * Fetch data from the Mozilla Ad Routing Service (MARS) unified ads API * This function is designed to get whichever ads types are needed (tiles, spocs) * * @param {Array} supportedAdTypes * @returns {object} Response object containing ad information from MARS */ async fetchData(supportedAdTypes) { const state = this.store.getState(); const headers = new Headers(); headers.append("content-type", "application/json"); const endpointBaseUrl = state.Prefs.values[PREF_UNIFIED_ADS_ENDPOINT]; const marsOhttpEnabled = Services.prefs.getBoolPref( "browser.newtabpage.activity-stream.unifiedAds.ohttp.enabled", false ); const ohttpRelayURL = Services.prefs.getStringPref( "browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL", "" ); const ohttpConfigURL = Services.prefs.getStringPref( "browser.newtabpage.activity-stream.discoverystream.ohttp.configURL", "" ); let blockedSponsors = this.store.getState().Prefs.values[PREF_UNIFIED_ADS_BLOCKED_LIST]; // Overwrite URL to Unified Ads endpoint const fetchUrl = `${endpointBaseUrl}v1/ads`; // Can include both tile and spoc placement data let placements = []; let responseData; let returnData = {}; // Determine which data needs to be fetched if (supportedAdTypes.tiles) { const tilesPlacementsArray = state.Prefs.values[ PREF_TILES_PLACEMENTS ]?.split(`,`) .map(s => s.trim()) .filter(item => item); const tilesCountsArray = state.Prefs.values[PREF_TILES_COUNTS]?.split(`,`) .map(s => s.trim()) .filter(item => item) .map(item => parseInt(item, 10)); const tilesPlacements = tilesPlacementsArray.map((placement, index) => ({ placement, count: tilesCountsArray[index], })); placements.push(...tilesPlacements); } // Determine which data needs to be fetched if (supportedAdTypes.spocs) { const spocPlacementsArray = state.Prefs.values[ PREF_SPOC_PLACEMENTS ]?.split(`,`) .map(s => s.trim()) .filter(item => item); const spocCountsArray = state.Prefs.values[PREF_SPOC_COUNTS]?.split(`,`) .map(s => s.trim()) .filter(item => item) .map(item => parseInt(item, 10)); const spocPlacements = spocPlacementsArray.map((placement, index) => ({ placement, count: spocCountsArray[index], })); returnData.spocPlacements = spocPlacements; placements.push(...spocPlacements); } const adsBackendConfig = state.Prefs.values?.adsBackendConfig || {}; let fetchPromise; const controller = new AbortController(); const { signal } = controller; const options = { method: "POST", headers, body: JSON.stringify({ context_id: await lazy.ContextId.request(), flags: adsBackendConfig, placements, blocks: blockedSponsors.split(","), }), credentials: "omit", signal, }; // Make Oblivious Fetch Request if (marsOhttpEnabled && ohttpConfigURL && ohttpRelayURL) { const config = await lazy.ObliviousHTTP.getOHTTPConfig(ohttpConfigURL); if (!config) { console.error( new Error( `OHTTP was configured for ${fetchUrl} but we couldn't fetch a valid config` ) ); return null; } fetchPromise = lazy.ObliviousHTTP.ohttpRequest( ohttpRelayURL, config, fetchUrl, options ); } else { fetchPromise = this.fetch(fetchUrl, options); } const response = await fetchPromise; if (response && response.status === 200) { responseData = await response.json(); } else { throw new Error( `Error fetching data: ${response.status} - ${response.statusText}` ); } if (supportedAdTypes.tiles) { const filteredRespDataTiles = Object.keys(responseData) .filter(key => key.startsWith("newtab_tile_")) .reduce((acc, key) => { acc[key] = responseData[key]; return acc; }, {}); const normalizedTileData = this._normalizeTileData(filteredRespDataTiles); returnData.tiles = normalizedTileData.tiles; } if (supportedAdTypes.spocs) { const filteredRespDataNonTiles = Object.keys(responseData) .filter(key => !key.startsWith("newtab_tile_")) .reduce((acc, key) => { acc[key] = responseData[key]; return acc; }, {}); returnData.spocs = filteredRespDataNonTiles.newtab_spocs; } return returnData; } /** * Init function that runs only from onAction at.INIT call. * * @param {boolean} isStartup=false * @returns {void} */ async init(isStartup = false) { if (this.isEnabled()) { await this.getAdsData(isStartup); } } /** * Sets cached data and dispatches at.ADS_UPDATE_{DATA_TYPE} event to update store with new ads data * * @param {boolean} isStartup * @returns {void} */ async update(isStartup) { await this.cache.set("ads", { ...(this.tiles ? { tiles: this.tiles } : {}), ...(this.spocs ? { spocs: this.spocs } : {}), ...(this.spocPlacements ? { spocPlacements: this.spocPlacements } : {}), lastUpdated: this.lastUpdated, }); if (this.tiles && this.tiles.length) { this.store.dispatch( ac.BroadcastToContent({ type: at.ADS_UPDATE_TILES, data: { tiles: this.tiles, }, meta: { isStartup, }, }) ); } if (this.spocs && this.spocs.length) { this.store.dispatch( ac.BroadcastToContent({ type: at.ADS_UPDATE_SPOCS, data: { spocs: this.spocs, spocPlacements: this.spocPlacements, }, meta: { isStartup, }, }) ); } } async onPrefChangedAction(action) { switch (action.data.name) { // AdsFeed Feature Prefs // Shortcuts or Stories Enabled/Disabled case PREF_UNIFIED_ADS_TILES_ENABLED: case PREF_UNIFIED_ADS_SPOCS_ENABLED: case PREF_UNIFIED_ADS_ADSFEED_ENABLED: case PREF_FEED_TOPSITES: case PREF_SYSTEM_TOPSITES: case PREF_SYSTEM_TOPSTORIES: case PREF_FEED_SECTION_TOPSTORIES: if (!this.isAdsFeedEnabled()) { // Only act on these prefs if AdsFeed is enabled break; } // TODO: Should we use the value of these prefs to determine what to do? if (this.isEnabled()) { await this.getAdsData(false); } else { await this.deleteUserAdsData(); await this.resetAdsFeed(); } break; case PREF_SHOW_SPONSORED_TOPSITES: case PREF_SHOW_SPONSORED: case PREF_SYSTEM_SHOW_SPONSORED: if (!this.isEnabled()) { // Only act on these prefs if AdsFeed is enabled break; } if (action.data.value) { await this.getAdsData(false); } else { await this.deleteUserAdsData(); await this.resetAdsFeed(); } break; } } async onAction(action) { switch (action.type) { case at.INIT: await this.init(true /* isStartup */); break; case at.UNINIT: break; case at.DISCOVERY_STREAM_DEV_SYSTEM_TICK: case at.SYSTEM_TICK: if (this.isEnabled()) { await this.getAdsData(false); } break; case at.PREF_CHANGED: await this.onPrefChangedAction(action); break; case at.DISCOVERY_STREAM_CONFIG_CHANGE: case at.DISCOVERY_STREAM_DEV_REFRESH_CACHE: // Event emitted from ASDevTools "Refresh Cache" button case at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE: // Event emitted from ASDevTools "Expire Cache" button // Clear cache await this.resetAdsFeed(); // Get new ads if (this.isEnabled()) { await this.getAdsData(false); } break; } } } /** * Creating a thin wrapper around PersistentCache, ObliviousHTTP and Date. * This makes it easier for us to write automated tests that simulate responses. */ AdsFeed.prototype.PersistentCache = (...args) => { return new lazy.PersistentCache(...args); }; AdsFeed.prototype.Date = () => { return Date; }; AdsFeed.prototype.ObliviousHTTP = (...args) => { return lazy.ObliviousHTTP(...args); };