tor-browser

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

NewTabAttributionService.sys.mjs (15765B)


      1 /* vim: set ts=2 sw=2 sts=2 et tw=80: */
      2 /* This Source Code Form is subject to the terms of the Mozilla Public
      3 * License, v. 2.0. If a copy of the MPL was not distributed with this
      4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      5 
      6 const lazy = {};
      7 
      8 ChromeUtils.defineESModuleGetters(lazy, {
      9  IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs",
     10  DAPSender: "resource://gre/modules/DAPSender.sys.mjs",
     11  ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs",
     12  HPKEConfigManager: "resource://gre/modules/HPKEConfigManager.sys.mjs",
     13  AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs",
     14 });
     15 
     16 const MAX_CONVERSIONS = 2;
     17 const MAX_LOOKBACK_DAYS = 30;
     18 const DAY_IN_MILLI = 1000 * 60 * 60 * 24;
     19 const CONVERSION_RESET_MILLI = 7 * DAY_IN_MILLI;
     20 
     21 const DAP_HPKE_PREF = "dap.ohttp.hpke";
     22 const DAP_RELAY_PREF = "dap.ohttp.relayURL";
     23 const MARS_ENDPOINT_PREF =
     24  "browser.newtabpage.activity-stream.unifiedAds.endpoint";
     25 const PREF_MARS_OHTTP_CONFIG =
     26  "browser.newtabpage.activity-stream.discoverystream.ohttp.configURL";
     27 const PREF_MARS_OHTTP_RELAY =
     28  "browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL";
     29 
     30 /**
     31 *
     32 */
     33 class NewTabAttributionService {
     34  /**
     35   * @typedef { 'view' | 'click' | 'default' } matchType - Available matching methodologies for conversion events.
     36   *
     37   * @typedef { 'view' | 'click' } eventType - A subset of matchType values that Newtab will register events.
     38   *
     39   * @typedef {object} task - DAP task settings.
     40   * @property {string} id - task id.
     41   * @property {string} vdaf - vdaf type.
     42   * @property {number} length - number of buckets.
     43   * @property {number} time_precision - time precision.
     44   *
     45   * @typedef {object} allocatedTask
     46   * @property {task} task - DAP task settings.
     47   * @property {number} defaultMeasurement - Measurement value used if budget is exceeded.
     48   * @property {number} index - Measurement value used if budget is not exceeded.
     49   *
     50   * @typedef {object} impression - stored event.
     51   * @property {allocatedTask} conversion - DAP task settings for conversion attribution.
     52   * @property {number} lastImpression - Timestamp in milliseconds for last touch matching.
     53   * @property {number} lastView - Timestamp in milliseconds for last view matching.
     54   * @property {number} lastClick - Timestamp in milliseconds for last click matching.
     55   *
     56   * @typedef {object} budget - stored budget.
     57   * @property {number} conversions - Number of conversions that have occurred in the budget period.
     58   * @property {number} nextReset - Timestamp in milliseconds for the end of the period this budget applies to.
     59   */
     60  #dapSenderInternal;
     61  #dateProvider;
     62  // eslint-disable-next-line no-unused-private-class-members
     63  #testDapOptions;
     64 
     65  constructor({ dapSender, dateProvider, testDapOptions } = {}) {
     66    this.#dapSenderInternal = dapSender;
     67    this.#dateProvider = dateProvider ?? Date;
     68    this.#testDapOptions = testDapOptions;
     69 
     70    this.dbName = "NewTabAttribution";
     71    this.impressionStoreName = "impressions";
     72    this.budgetStoreName = "budgets";
     73    this.storeNames = [this.impressionStoreName, this.budgetStoreName];
     74    this.dbVersion = 1;
     75    this.models = {
     76      default: "lastImpression",
     77      view: "lastView",
     78      click: "lastClick",
     79    };
     80  }
     81 
     82  get #dapSender() {
     83    return this.#dapSenderInternal || lazy.DAPSender;
     84  }
     85 
     86  #now() {
     87    return this.#dateProvider.now();
     88  }
     89 
     90  #getTrainhopConfig() {
     91    return (
     92      lazy.AboutNewTab.activityStream?.store.getState().Prefs.values
     93        .trainhopConfig ?? {}
     94    );
     95  }
     96 
     97  /**
     98   * onAttributionEvent stores an event locally for an attributable interaction on Newtab.
     99   *
    100   * @param {eventType} type - The type of event.
    101   * @param {*} params - Attribution task details & partner, to enable attribution matching
    102   *  with this event and submission to DAP.
    103   */
    104  async onAttributionEvent(type, params) {
    105    try {
    106      const now = this.#now();
    107 
    108      if (
    109        !params ||
    110        !params.partner_id ||
    111        params.index === undefined ||
    112        params.index === null
    113      ) {
    114        return;
    115      }
    116 
    117      const impression = await this.#getImpression(params.partner_id, {
    118        conversion: {
    119          index: params.index,
    120        },
    121      });
    122 
    123      const prop = this.#getModelProp(type);
    124      impression.lastImpression = now;
    125      impression[prop] = now;
    126 
    127      await this.#updateImpression(params.partner_id, impression);
    128    } catch (e) {
    129      console.error(e);
    130    }
    131  }
    132 
    133  /**
    134   * Resets all partner budgets and clears stored impressions,
    135   * preparing for a new attribution conversion cycle.
    136   */
    137  async onAttributionReset() {
    138    try {
    139      const now = this.#now();
    140 
    141      // Clear impressions so future conversions won't match outdated impressions
    142      const impressionStore = await this.#getImpressionStore();
    143      await impressionStore.clear();
    144 
    145      // Reset budgets
    146      const budgetStore = await this.#getBudgetStore();
    147      const partnerIds = await budgetStore.getAllKeys();
    148 
    149      for (const partnerId of partnerIds) {
    150        const budget = await budgetStore.get(partnerId);
    151        // Currently clobbers the budget, but will work if any future data is added to DB
    152        const updatedBudget = {
    153          ...budget,
    154          conversions: 0,
    155          nextReset: now + CONVERSION_RESET_MILLI,
    156        };
    157 
    158        await budgetStore.put(updatedBudget, partnerId);
    159      }
    160    } catch (e) {
    161      console.error(e);
    162    }
    163  }
    164 
    165  /**
    166   * onAttributionConversion checks for eligible Newtab events and submits
    167   * a DAP report.
    168   *
    169   * @param {string} partnerId - The partner that the conversion occured for. Compared against
    170   *  local events to see if any of them are eligible.
    171   * @param {number} lookbackDays - The number of days prior to now that an event can be for it
    172   *  to be eligible.
    173   * @param {matchType} impressionType - How the matching of events is determined.
    174   *  'view': attributes the most recent eligible view event.
    175   *  'click': attributes the most recent eligible click event.
    176   *  'default': attributes the most recent eligible event of any type.
    177   */
    178  async onAttributionConversion(partnerId, lookbackDays, impressionType) {
    179    try {
    180      const trainhopConfig = this.#getTrainhopConfig();
    181      const attributionConfig = trainhopConfig.attribution || {};
    182 
    183      const maxLookbackDays =
    184        attributionConfig.maxLookbackDays ?? MAX_LOOKBACK_DAYS;
    185      const maxConversions =
    186        attributionConfig.maxConversions ?? MAX_CONVERSIONS;
    187 
    188      if (lookbackDays > maxLookbackDays) {
    189        return;
    190      }
    191      // we don't want to request the gateway key at time of conversion to avoid an IP address leak
    192      const dapHpke = Services.prefs.getCharPref(
    193        DAP_HPKE_PREF,
    194        "gAAgJSO22Y3HKzRSese15JtQVuuFfOIcTrZ56lQ5kDQwS0oABAABAAE"
    195      );
    196      const ohttpRelayURL = Services.prefs.getCharPref(
    197        DAP_RELAY_PREF,
    198        "https://mozilla-ohttp-dap.mozilla.fastly-edge.com/"
    199      );
    200      const now = this.#now();
    201 
    202      const budget = await this.#getBudget(partnerId, now);
    203      const impression = await this.#findImpression(
    204        partnerId,
    205        lookbackDays,
    206        impressionType,
    207        now
    208      );
    209 
    210      const receivedTaskConfig = await this.#getTaskConfig(partnerId);
    211 
    212      if (!receivedTaskConfig) {
    213        return;
    214      }
    215 
    216      // Need to rename task_id to id for DAP report submission.
    217      const taskConfig = {
    218        ...receivedTaskConfig,
    219        id: receivedTaskConfig.task_id,
    220      };
    221 
    222      let measurement = receivedTaskConfig.default_measurement;
    223      let budgetSpend = 0;
    224      if (budget.conversions < maxConversions && impression) {
    225        budgetSpend = 1;
    226        const conversionIndex = impression.conversion.index;
    227        if (
    228          receivedTaskConfig.length > conversionIndex &&
    229          conversionIndex !== undefined
    230        ) {
    231          measurement = conversionIndex;
    232        }
    233      }
    234 
    235      await this.#updateBudget(budget, budgetSpend, partnerId);
    236 
    237      const options = {};
    238      if (dapHpke) {
    239        options.ohttp_hpke = lazy.HPKEConfigManager.decodeKey(dapHpke);
    240      }
    241 
    242      if (ohttpRelayURL) {
    243        options.ohttp_relay = ohttpRelayURL;
    244      }
    245 
    246      await this.#dapSender.sendDAPMeasurement(
    247        taskConfig,
    248        measurement,
    249        options
    250      );
    251    } catch (e) {
    252      console.error(e);
    253    }
    254  }
    255 
    256  /**
    257   * findImpression queries the local events to find an attributable event.
    258   *
    259   * @param {string} partnerId - Partner the event must be associated with.
    260   * @param {number} lookbackDays - Maximum number of days ago that the event occurred for it to
    261   *  be eligible.
    262   * @param {matchType} impressionType - How the matching of events is determined. Determines what
    263   *  timestamp property to compare against.
    264   * @param {number} now - Timestamp in milliseconds when the conversion event was triggered
    265   * @returns {Promise<impression|undefined>} - The impression that most recently occurred matching the
    266   *  search criteria.
    267   */
    268  async #findImpression(partnerId, lookbackDays, impressionType, now) {
    269    // Get impressions for the partner
    270    const impressionStore = await this.#getImpressionStore();
    271    const impressions = await this.#getPartnerImpressions(
    272      impressionStore,
    273      partnerId
    274    );
    275 
    276    // Determine what timestamp to compare against for the matching methodology
    277    const prop = this.#getModelProp(impressionType);
    278 
    279    // Find the most relevant impression
    280    const lookbackWindow = now - lookbackDays * DAY_IN_MILLI;
    281    return (
    282      impressions
    283        // Filter by lookback days
    284        .filter(impression => impression[prop] >= lookbackWindow)
    285        // Get the impression with the most recent interaction
    286        .reduce(
    287          (cur, impression) =>
    288            !cur || impression[prop] > cur[prop] ? impression : cur,
    289          null
    290        )
    291    );
    292  }
    293 
    294  /**
    295   * getImpression searches existing events for the partner and retuns the event
    296   * if it is found, defaulting to the passed in impression if there are none. This
    297   * enables timestamp fields of the stored event to be updated or carried forward.
    298   *
    299   * @param {string} partnerId - partner this event is associated with.
    300   * @param {impression} defaultImpression - event to use if it has not been seen previously.
    301   * @returns {Promise<impression>}
    302   */
    303  async #getImpression(partnerId, defaultImpression) {
    304    const impressionStore = await this.#getImpressionStore();
    305    const impressions = await this.#getPartnerImpressions(
    306      impressionStore,
    307      partnerId
    308    );
    309    const impression = impressions.find(r =>
    310      this.#compareImpression(r, defaultImpression)
    311    );
    312 
    313    return impression ?? defaultImpression;
    314  }
    315 
    316  async #getTaskConfig(partnerId) {
    317    const baseUrl = Services.prefs.getCharPref(MARS_ENDPOINT_PREF, "");
    318    const endpoint = `${baseUrl}/v1/attribution?partner_id=${encodeURIComponent(
    319      partnerId
    320    )}`;
    321    const ohttpConfigURL = Services.prefs.getCharPref(
    322      PREF_MARS_OHTTP_CONFIG,
    323      ""
    324    );
    325    const ohttpRelayURL = Services.prefs.getCharPref(PREF_MARS_OHTTP_RELAY, "");
    326 
    327    if (!partnerId || !endpoint || !ohttpRelayURL || !ohttpConfigURL) {
    328      return null;
    329    }
    330    const controller = new AbortController();
    331    const { signal } = controller;
    332    let config = await lazy.ObliviousHTTP.getOHTTPConfig(ohttpConfigURL);
    333    if (!config) {
    334      console.error(
    335        new Error(
    336          `OHTTP was configured for ${endpoint} but we couldn't fetch a valid config`
    337        )
    338      );
    339      return null;
    340    }
    341    try {
    342      const response = await lazy.ObliviousHTTP.ohttpRequest(
    343        ohttpRelayURL,
    344        config,
    345        endpoint,
    346        {
    347          headers: {},
    348          signal,
    349        }
    350      );
    351      return response.json();
    352    } catch (error) {
    353      console.error(
    354        `Failed to make OHTTP request for unattributed task: ${error.message}`,
    355        error
    356      );
    357      return null;
    358    }
    359  }
    360 
    361  /**
    362   * updateImpression stores the passed event, either updating the record
    363   * if this event was already seen, or appending to the list of events if it is new.
    364   *
    365   * @param {string} partnerId - partner this event is associated with.
    366   * @param {impression} impression - event to update.
    367   */
    368  async #updateImpression(partnerId, impression) {
    369    const impressionStore = await this.#getImpressionStore();
    370    let impressions = await this.#getPartnerImpressions(
    371      impressionStore,
    372      partnerId
    373    );
    374 
    375    const i = impressions.findIndex(r =>
    376      this.#compareImpression(r, impression)
    377    );
    378    if (i < 0) {
    379      impressions.push(impression);
    380    } else {
    381      impressions[i] = impression;
    382    }
    383 
    384    await impressionStore.put(impressions, partnerId);
    385  }
    386 
    387  /**
    388   * @param {impression} cur
    389   * @param {impression} impression
    390   * @returns {boolean} true if cur and impression have the same index
    391   */
    392  #compareImpression(cur, impression) {
    393    return cur.conversion.index === impression.conversion.index;
    394  }
    395 
    396  /**
    397   * getBudget returns the current budget available for the partner.
    398   *
    399   * @param {string} partnerId - partner to look up budget for.
    400   * @param {number} now - Timestamp in milliseconds.
    401   * @returns {Promise<budget>} the current budget for the partner.
    402   */
    403  async #getBudget(partnerId, now) {
    404    const budgetStore = await this.#getBudgetStore();
    405    const budget = await budgetStore.get(partnerId);
    406 
    407    if (!budget || now > budget.nextReset) {
    408      return {
    409        conversions: 0,
    410        nextReset: now + CONVERSION_RESET_MILLI,
    411      };
    412    }
    413 
    414    return budget;
    415  }
    416 
    417  /**
    418   * updateBudget updates the stored budget to indicate some has been used.
    419   *
    420   * @param {budget} budget - current budget to be modified.
    421   * @param {number} value - amount of budget that has been used.
    422   * @param {string} partnerId - partner this budget is for.
    423   */
    424  async #updateBudget(budget, value, partnerId) {
    425    const budgetStore = await this.#getBudgetStore();
    426    budget.conversions += value;
    427    await budgetStore.put(budget, partnerId);
    428  }
    429 
    430  /**
    431   * @param {ObjectStore} impressionStore - Promise-based wrapped IDBObjectStore.
    432   * @param {string} partnerId - partner to look up impressions for.
    433   * @returns {Promise<Array<impression>>} impressions associated with the partner.
    434   */
    435  async #getPartnerImpressions(impressionStore, partnerId) {
    436    const impressions = (await impressionStore.get(partnerId)) ?? [];
    437    return impressions;
    438  }
    439 
    440  async #getImpressionStore() {
    441    return await this.#getStore(this.impressionStoreName);
    442  }
    443 
    444  async #getBudgetStore() {
    445    return await this.#getStore(this.budgetStoreName);
    446  }
    447 
    448  async #getStore(storeName) {
    449    return (await this.#db).objectStore(storeName, "readwrite");
    450  }
    451 
    452  get #db() {
    453    return this._db || (this._db = this.#createOrOpenDb());
    454  }
    455 
    456  async #createOrOpenDb() {
    457    try {
    458      return await this.#openDatabase();
    459    } catch {
    460      await lazy.IndexedDB.deleteDatabase(this.dbName);
    461      return this.#openDatabase();
    462    }
    463  }
    464 
    465  async #openDatabase() {
    466    return await lazy.IndexedDB.open(this.dbName, this.dbVersion, db => {
    467      this.storeNames.forEach(store => {
    468        if (!db.objectStoreNames.contains(store)) {
    469          db.createObjectStore(store);
    470        }
    471      });
    472    });
    473  }
    474 
    475  /**
    476   * getModelProp returns the property name associated with a given matching
    477   * methodology.
    478   *
    479   * @param {matchType} type
    480   * @returns {string} The name of the timestamp property to check against.
    481   */
    482  #getModelProp(type) {
    483    return this.models[type] ?? this.models.default;
    484  }
    485 }
    486 
    487 const newTabAttributionService = new NewTabAttributionService();
    488 
    489 export {
    490  newTabAttributionService,
    491  NewTabAttributionService as NewTabAttributionServiceClass,
    492 };