/* vim: set ts=2 sw=2 sts=2 et tw=80: */ /* 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 = {}; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; ChromeUtils.defineESModuleGetters(lazy, { IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", DAPTelemetrySender: "resource://gre/modules/DAPTelemetrySender.sys.mjs", HPKEConfigManager: "resource://gre/modules/HPKEConfigManager.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", }); XPCOMUtils.defineLazyPreferenceGetter( lazy, "gIsTelemetrySendingEnabled", "datareporting.healthreport.uploadEnabled", true ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "gIsPPAEnabled", "dom.private-attribution.submission.enabled", true ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "gOhttpRelayUrl", "toolkit.shopping.ohttpRelayURL" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "gOhttpGatewayKeyUrl", "toolkit.shopping.ohttpConfigURL" ); const MAX_CONVERSIONS = 2; const DAY_IN_MILLI = 1000 * 60 * 60 * 24; const CONVERSION_RESET_MILLI = 7 * DAY_IN_MILLI; const DAP_TIMEOUT_MILLI = 30000; /** * */ export class PrivateAttributionService { constructor({ dapTelemetrySender, dateProvider, testForceEnabled, testDapOptions, } = {}) { this._dapTelemetrySender = dapTelemetrySender; this._dateProvider = dateProvider ?? Date; this._testForceEnabled = testForceEnabled; this._testDapOptions = testDapOptions; this.dbName = "PrivateAttribution"; this.impressionStoreName = "impressions"; this.budgetStoreName = "budgets"; this.storeNames = [this.impressionStoreName, this.budgetStoreName]; this.dbVersion = 1; this.models = { default: "lastImpression", view: "lastView", click: "lastClick", }; } get dapTelemetrySender() { return this._dapTelemetrySender || lazy.DAPTelemetrySender; } now() { return this._dateProvider.now(); } async onAttributionEvent(sourceHost, type, index, ad, targetHost) { if (!this.isEnabled()) { return; } const now = this.now(); try { const impressionStore = await this.getImpressionStore(); const impression = await this.getImpression(impressionStore, ad, { index, target: targetHost, source: sourceHost, }); const prop = this.getModelProp(type); impression.index = index; impression.lastImpression = now; impression[prop] = now; await this.updateImpression(impressionStore, ad, impression); } catch (e) { console.error(e); } } async onAttributionConversion( targetHost, task, histogramSize, lookbackDays, impressionType, ads, sourceHosts ) { if (!this.isEnabled()) { return; } const now = this.now(); try { const budget = await this.getBudget(targetHost, now); const impression = await this.findImpression( ads, targetHost, sourceHosts, impressionType, lookbackDays, histogramSize, now ); let index = 0; let value = 0; if (budget.conversions < MAX_CONVERSIONS && impression) { index = impression.index; value = 1; } await this.updateBudget(budget, value, targetHost); await this.sendDapReport(task, index, histogramSize, value); } catch (e) { console.error(e); } } async findImpression(ads, target, sources, model, days, histogramSize, now) { let impressions = []; const impressionStore = await this.getImpressionStore(); // Get matching ad impressions if (ads && ads.length) { for (var i = 0; i < ads.length; i++) { impressions = impressions.concat( (await impressionStore.get(ads[i])) ?? [] ); } } else { impressions = (await impressionStore.getAll()).flat(1); } // Set attribution model properties const prop = this.getModelProp(model); // Find the most relevant impression const lookbackWindow = now - days * DAY_IN_MILLI; return ( impressions // Filter by target, sources, and lookback days .filter( impression => impression.target === target && (!sources || sources.includes(impression.source)) && impression[prop] >= lookbackWindow && impression.index < histogramSize ) // Get the impression with the most recent interaction .reduce( (cur, impression) => !cur || impression[prop] > cur[prop] ? impression : cur, null ) ); } async getImpression(impressionStore, ad, defaultImpression) { const impressions = (await impressionStore.get(ad)) ?? []; const impression = impressions.find(r => this.compareImpression(r, defaultImpression) ); return impression ?? defaultImpression; } async updateImpression(impressionStore, key, impression) { let impressions = (await impressionStore.get(key)) ?? []; const i = impressions.findIndex(r => this.compareImpression(r, impression)); if (i < 0) { impressions.push(impression); } else { impressions[i] = impression; } await impressionStore.put(impressions, key); } compareImpression(cur, impression) { return cur.source === impression.source && cur.target === impression.target; } async getBudget(target, now) { const budgetStore = await this.getBudgetStore(); const budget = await budgetStore.get(target); if (!budget || now > budget.nextReset) { return { conversions: 0, nextReset: now + CONVERSION_RESET_MILLI, }; } return budget; } async updateBudget(budget, value, target) { const budgetStore = await this.getBudgetStore(); budget.conversions += value; await budgetStore.put(budget, target); } async getImpressionStore() { return await this.getStore(this.impressionStoreName); } async getBudgetStore() { return await this.getStore(this.budgetStoreName); } async getStore(storeName) { return (await this.db).objectStore(storeName, "readwrite"); } get db() { return this._db || (this._db = this.createOrOpenDb()); } async createOrOpenDb() { try { return await this.openDatabase(); } catch { await lazy.IndexedDB.deleteDatabase(this.dbName); return this.openDatabase(); } } async openDatabase() { return await lazy.IndexedDB.open(this.dbName, this.dbVersion, db => { this.storeNames.forEach(store => { if (!db.objectStoreNames.contains(store)) { db.createObjectStore(store); } }); }); } async sendDapReport(id, index, size, value) { const task = { id, vdaf: "sumvec", bits: 8, length: size, time_precision: 60, }; const measurement = new Array(size).fill(0); measurement[index] = value; let options = { timeout: DAP_TIMEOUT_MILLI, ohttp_relay: lazy.gOhttpRelayUrl, ...this._testDapOptions, }; if (options.ohttp_relay) { // Fetch the OHTTP-Gateway-HPKE key if not provided yet. if (!options.ohttp_hpke) { const controller = new AbortController(); lazy.setTimeout(() => controller.abort(), DAP_TIMEOUT_MILLI); options.ohttp_hpke = await lazy.HPKEConfigManager.get( lazy.gOhttpGatewayKeyUrl, { maxAge: DAY_IN_MILLI, abortSignal: controller.signal, } ); } } else if (!this._testForceEnabled) { // Except for testing, do no allow PPA to bypass OHTTP. throw new Error("PPA requires an OHTTP relay for submission"); } await this.dapTelemetrySender.sendDAPMeasurement( task, measurement, options ); } getModelProp(type) { return this.models[type ? type : "default"]; } isEnabled() { return ( this._testForceEnabled || (lazy.gIsTelemetrySendingEnabled && AppConstants.MOZ_TELEMETRY_REPORTING && lazy.gIsPPAEnabled) ); } QueryInterface = ChromeUtils.generateQI([Ci.nsIPrivateAttributionService]); }