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 };