Source code

Revision control

Copy as Markdown

Other Tools

/* 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 = {
};
ChromeUtils.defineESModuleGetters(lazy, {
ContextId: "moz-src:///browser/modules/ContextId.sys.mjs",
ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs",
});
import {
actionTypes as at,
actionCreators as ac,
// 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),
});
}
isEnabled() {
this.loaded = true;
// Check if AdsFeed is enabled
const adsFeedEnabled =
this.store.getState().Prefs.values[PREF_UNIFIED_ADS_ADSFEED_ENABLED];
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);
}
let fetchPromise;
const controller = new AbortController();
const { signal } = controller;
const options = {
method: "POST",
headers,
body: JSON.stringify({
context_id: await lazy.ContextId.request(),
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) {
case PREF_UNIFIED_ADS_TILES_ENABLED:
case PREF_UNIFIED_ADS_SPOCS_ENABLED:
case PREF_UNIFIED_ADS_ADSFEED_ENABLED:
// TODO: Should we use the value of these prefs to determine what to do?
// AdsFeed Feature Prefs
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;
// Shortcuts or Stories Enabled/Disabled
case PREF_FEED_TOPSITES:
case PREF_SYSTEM_TOPSITES:
case PREF_SYSTEM_TOPSTORIES:
case PREF_FEED_SECTION_TOPSTORIES:
// 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;
}
}
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: // Event emitted from ASDevTools "Reset 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);
};