tor-browser

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

PrivateAttributionService.sys.mjs (8447B)


      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 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      9 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
     10 
     11 ChromeUtils.defineESModuleGetters(lazy, {
     12  IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs",
     13  DAPTelemetrySender: "resource://gre/modules/DAPTelemetrySender.sys.mjs",
     14  HPKEConfigManager: "resource://gre/modules/HPKEConfigManager.sys.mjs",
     15  setTimeout: "resource://gre/modules/Timer.sys.mjs",
     16 });
     17 
     18 XPCOMUtils.defineLazyPreferenceGetter(
     19  lazy,
     20  "gIsTelemetrySendingEnabled",
     21  "datareporting.healthreport.uploadEnabled",
     22  true
     23 );
     24 
     25 XPCOMUtils.defineLazyPreferenceGetter(
     26  lazy,
     27  "gIsPPAEnabled",
     28  "dom.private-attribution.submission.enabled",
     29  true
     30 );
     31 
     32 XPCOMUtils.defineLazyPreferenceGetter(
     33  lazy,
     34  "gOhttpRelayUrl",
     35  "toolkit.shopping.ohttpRelayURL"
     36 );
     37 XPCOMUtils.defineLazyPreferenceGetter(
     38  lazy,
     39  "gOhttpGatewayKeyUrl",
     40  "toolkit.shopping.ohttpConfigURL"
     41 );
     42 
     43 const MAX_CONVERSIONS = 2;
     44 const DAY_IN_MILLI = 1000 * 60 * 60 * 24;
     45 const CONVERSION_RESET_MILLI = 7 * DAY_IN_MILLI;
     46 const DAP_TIMEOUT_MILLI = 30000;
     47 
     48 /**
     49 *
     50 */
     51 export class PrivateAttributionService {
     52  constructor({
     53    dapTelemetrySender,
     54    dateProvider,
     55    testForceEnabled,
     56    testDapOptions,
     57  } = {}) {
     58    this._dapTelemetrySender = dapTelemetrySender;
     59    this._dateProvider = dateProvider ?? Date;
     60    this._testForceEnabled = testForceEnabled;
     61    this._testDapOptions = testDapOptions;
     62 
     63    this.dbName = "PrivateAttribution";
     64    this.impressionStoreName = "impressions";
     65    this.budgetStoreName = "budgets";
     66    this.storeNames = [this.impressionStoreName, this.budgetStoreName];
     67    this.dbVersion = 1;
     68    this.models = {
     69      default: "lastImpression",
     70      view: "lastView",
     71      click: "lastClick",
     72    };
     73  }
     74 
     75  get dapTelemetrySender() {
     76    return this._dapTelemetrySender || lazy.DAPTelemetrySender;
     77  }
     78 
     79  now() {
     80    return this._dateProvider.now();
     81  }
     82 
     83  async onAttributionEvent(sourceHost, type, index, ad, targetHost) {
     84    if (!this.isEnabled()) {
     85      return;
     86    }
     87 
     88    const now = this.now();
     89 
     90    try {
     91      const impressionStore = await this.getImpressionStore();
     92 
     93      const impression = await this.getImpression(impressionStore, ad, {
     94        index,
     95        target: targetHost,
     96        source: sourceHost,
     97      });
     98 
     99      const prop = this.getModelProp(type);
    100      impression.index = index;
    101      impression.lastImpression = now;
    102      impression[prop] = now;
    103 
    104      await this.updateImpression(impressionStore, ad, impression);
    105    } catch (e) {
    106      console.error(e);
    107    }
    108  }
    109 
    110  async onAttributionConversion(
    111    targetHost,
    112    task,
    113    histogramSize,
    114    lookbackDays,
    115    impressionType,
    116    ads,
    117    sourceHosts
    118  ) {
    119    if (!this.isEnabled()) {
    120      return;
    121    }
    122 
    123    const now = this.now();
    124 
    125    try {
    126      const budget = await this.getBudget(targetHost, now);
    127      const impression = await this.findImpression(
    128        ads,
    129        targetHost,
    130        sourceHosts,
    131        impressionType,
    132        lookbackDays,
    133        histogramSize,
    134        now
    135      );
    136 
    137      let index = 0;
    138      let value = 0;
    139      if (budget.conversions < MAX_CONVERSIONS && impression) {
    140        index = impression.index;
    141        value = 1;
    142      }
    143 
    144      await this.updateBudget(budget, value, targetHost);
    145      await this.sendDapReport(task, index, histogramSize, value);
    146    } catch (e) {
    147      console.error(e);
    148    }
    149  }
    150 
    151  async findImpression(ads, target, sources, model, days, histogramSize, now) {
    152    let impressions = [];
    153 
    154    const impressionStore = await this.getImpressionStore();
    155 
    156    // Get matching ad impressions
    157    if (ads && ads.length) {
    158      for (var i = 0; i < ads.length; i++) {
    159        impressions = impressions.concat(
    160          (await impressionStore.get(ads[i])) ?? []
    161        );
    162      }
    163    } else {
    164      impressions = (await impressionStore.getAll()).flat(1);
    165    }
    166 
    167    // Set attribution model properties
    168    const prop = this.getModelProp(model);
    169 
    170    // Find the most relevant impression
    171    const lookbackWindow = now - days * DAY_IN_MILLI;
    172    return (
    173      impressions
    174        // Filter by target, sources, and lookback days
    175        .filter(
    176          impression =>
    177            impression.target === target &&
    178            (!sources || sources.includes(impression.source)) &&
    179            impression[prop] >= lookbackWindow &&
    180            impression.index < histogramSize
    181        )
    182        // Get the impression with the most recent interaction
    183        .reduce(
    184          (cur, impression) =>
    185            !cur || impression[prop] > cur[prop] ? impression : cur,
    186          null
    187        )
    188    );
    189  }
    190 
    191  async getImpression(impressionStore, ad, defaultImpression) {
    192    const impressions = (await impressionStore.get(ad)) ?? [];
    193    const impression = impressions.find(r =>
    194      this.compareImpression(r, defaultImpression)
    195    );
    196 
    197    return impression ?? defaultImpression;
    198  }
    199 
    200  async updateImpression(impressionStore, key, impression) {
    201    let impressions = (await impressionStore.get(key)) ?? [];
    202 
    203    const i = impressions.findIndex(r => this.compareImpression(r, impression));
    204    if (i < 0) {
    205      impressions.push(impression);
    206    } else {
    207      impressions[i] = impression;
    208    }
    209 
    210    await impressionStore.put(impressions, key);
    211  }
    212 
    213  compareImpression(cur, impression) {
    214    return cur.source === impression.source && cur.target === impression.target;
    215  }
    216 
    217  async getBudget(target, now) {
    218    const budgetStore = await this.getBudgetStore();
    219    const budget = await budgetStore.get(target);
    220 
    221    if (!budget || now > budget.nextReset) {
    222      return {
    223        conversions: 0,
    224        nextReset: now + CONVERSION_RESET_MILLI,
    225      };
    226    }
    227 
    228    return budget;
    229  }
    230 
    231  async updateBudget(budget, value, target) {
    232    const budgetStore = await this.getBudgetStore();
    233    budget.conversions += value;
    234    await budgetStore.put(budget, target);
    235  }
    236 
    237  async getImpressionStore() {
    238    return await this.getStore(this.impressionStoreName);
    239  }
    240 
    241  async getBudgetStore() {
    242    return await this.getStore(this.budgetStoreName);
    243  }
    244 
    245  async getStore(storeName) {
    246    return (await this.db).objectStore(storeName, "readwrite");
    247  }
    248 
    249  get db() {
    250    return this._db || (this._db = this.createOrOpenDb());
    251  }
    252 
    253  async createOrOpenDb() {
    254    try {
    255      return await this.openDatabase();
    256    } catch {
    257      await lazy.IndexedDB.deleteDatabase(this.dbName);
    258      return this.openDatabase();
    259    }
    260  }
    261 
    262  async openDatabase() {
    263    return await lazy.IndexedDB.open(this.dbName, this.dbVersion, db => {
    264      this.storeNames.forEach(store => {
    265        if (!db.objectStoreNames.contains(store)) {
    266          db.createObjectStore(store);
    267        }
    268      });
    269    });
    270  }
    271 
    272  async sendDapReport(id, index, size, value) {
    273    const task = {
    274      id,
    275      vdaf: "sumvec",
    276      bits: 8,
    277      length: size,
    278      time_precision: 60,
    279    };
    280 
    281    const measurement = new Array(size).fill(0);
    282    measurement[index] = value;
    283 
    284    let options = {
    285      timeout: DAP_TIMEOUT_MILLI,
    286      ohttp_relay: lazy.gOhttpRelayUrl,
    287      ...this._testDapOptions,
    288    };
    289 
    290    if (options.ohttp_relay) {
    291      // Fetch the OHTTP-Gateway-HPKE key if not provided yet.
    292      if (!options.ohttp_hpke) {
    293        const controller = new AbortController();
    294        lazy.setTimeout(() => controller.abort(), DAP_TIMEOUT_MILLI);
    295 
    296        options.ohttp_hpke = await lazy.HPKEConfigManager.get(
    297          lazy.gOhttpGatewayKeyUrl,
    298          {
    299            maxAge: DAY_IN_MILLI,
    300            abortSignal: controller.signal,
    301          }
    302        );
    303      }
    304    } else if (!this._testForceEnabled) {
    305      // Except for testing, do no allow PPA to bypass OHTTP.
    306      throw new Error("PPA requires an OHTTP relay for submission");
    307    }
    308 
    309    await this.dapTelemetrySender.sendDAPMeasurement(
    310      task,
    311      measurement,
    312      options
    313    );
    314  }
    315 
    316  getModelProp(type) {
    317    return this.models[type ? type : "default"];
    318  }
    319 
    320  isEnabled() {
    321    return (
    322      this._testForceEnabled ||
    323      (lazy.gIsTelemetrySendingEnabled &&
    324        AppConstants.MOZ_TELEMETRY_REPORTING &&
    325        lazy.gIsPPAEnabled)
    326    );
    327  }
    328 
    329  QueryInterface = ChromeUtils.generateQI([Ci.nsIPrivateAttributionService]);
    330 }