tor-browser

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

PushRecord.sys.mjs (9376B)


      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 file,
      3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
      6 
      7 const lazy = {};
      8 
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  EventDispatcher: "resource://gre/modules/Messaging.sys.mjs",
     11  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
     12  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     13 });
     14 
     15 const prefs = Services.prefs.getBranch("dom.push.");
     16 
     17 /**
     18 * The push subscription record, stored in IndexedDB.
     19 */
     20 export function PushRecord(props) {
     21  this.pushEndpoint = props.pushEndpoint;
     22  this.scope = props.scope;
     23  this.originAttributes = props.originAttributes;
     24  this.pushCount = props.pushCount || 0;
     25  this.lastPush = props.lastPush || 0;
     26  this.p256dhPublicKey = props.p256dhPublicKey;
     27  this.p256dhPrivateKey = props.p256dhPrivateKey;
     28  this.authenticationSecret = props.authenticationSecret;
     29  this.systemRecord = !!props.systemRecord;
     30  this.appServerKey = props.appServerKey;
     31  this.recentMessageIDs = props.recentMessageIDs;
     32  this.setQuota(props.quota);
     33  this.ctime = typeof props.ctime === "number" ? props.ctime : 0;
     34 }
     35 
     36 PushRecord.prototype = {
     37  setQuota(suggestedQuota) {
     38    if (this.quotaApplies()) {
     39      let quota = +suggestedQuota;
     40      this.quota =
     41        quota >= 0 ? quota : prefs.getIntPref("maxQuotaPerSubscription");
     42    } else {
     43      this.quota = Infinity;
     44    }
     45  },
     46 
     47  resetQuota() {
     48    this.quota = this.quotaApplies()
     49      ? prefs.getIntPref("maxQuotaPerSubscription")
     50      : Infinity;
     51  },
     52 
     53  updateQuota(lastVisit) {
     54    if (this.isExpired() || !this.quotaApplies()) {
     55      // Ignore updates if the registration is already expired, or isn't
     56      // subject to quota.
     57      return;
     58    }
     59    if (lastVisit < 0) {
     60      // If the user cleared their history, but retained the push permission,
     61      // mark the registration as expired.
     62      this.quota = 0;
     63      return;
     64    }
     65    if (lastVisit > this.lastPush) {
     66      // If the user visited the site since the last time we received a
     67      // notification, reset the quota. `Math.max(0, ...)` ensures the
     68      // last visit date isn't in the future.
     69      let daysElapsed = Math.max(
     70        0,
     71        (Date.now() - lastVisit) / 24 / 60 / 60 / 1000
     72      );
     73      this.quota = Math.min(
     74        Math.round(8 * Math.pow(daysElapsed, -0.8)),
     75        prefs.getIntPref("maxQuotaPerSubscription")
     76      );
     77    }
     78  },
     79 
     80  receivedPush(lastVisit) {
     81    this.updateQuota(lastVisit);
     82    this.pushCount++;
     83    this.lastPush = Date.now();
     84  },
     85 
     86  /**
     87   * Records a message ID sent to this push registration. We track the last few
     88   * messages sent to each registration to avoid firing duplicate events for
     89   * unacknowledged messages.
     90   */
     91  noteRecentMessageID(id) {
     92    if (this.recentMessageIDs) {
     93      this.recentMessageIDs.unshift(id);
     94    } else {
     95      this.recentMessageIDs = [id];
     96    }
     97    // Drop older message IDs from the end of the list.
     98    let maxRecentMessageIDs = Math.min(
     99      this.recentMessageIDs.length,
    100      Math.max(prefs.getIntPref("maxRecentMessageIDsPerSubscription"), 0)
    101    );
    102    this.recentMessageIDs.length = maxRecentMessageIDs || 0;
    103  },
    104 
    105  hasRecentMessageID(id) {
    106    return this.recentMessageIDs && this.recentMessageIDs.includes(id);
    107  },
    108 
    109  reduceQuota() {
    110    if (!this.quotaApplies()) {
    111      return;
    112    }
    113    this.quota = Math.max(this.quota - 1, 0);
    114  },
    115 
    116  /**
    117   * Queries the Places database for the last time a user visited the site
    118   * associated with a push registration.
    119   *
    120   * @returns {Promise} A promise resolved with either the last time the user
    121   *  visited the site, or `-Infinity` if the site is not in the user's history.
    122   *  The time is expressed in milliseconds since Epoch.
    123   */
    124  async getLastVisit() {
    125    if (!this.quotaApplies() || this.isTabOpen()) {
    126      // If the registration isn't subject to quota, or the user already
    127      // has the site open, skip expensive database queries.
    128      return Date.now();
    129    }
    130 
    131    if (AppConstants.MOZ_GECKOVIEW_HISTORY) {
    132      let result = await lazy.EventDispatcher.instance.sendRequestForResult({
    133        type: "History:GetPrePathLastVisitedTimeMilliseconds",
    134        prePath: this.uri.prePath,
    135      });
    136      return result == 0 ? -Infinity : result;
    137    }
    138 
    139    // Places History transition types that can fire a
    140    // `pushsubscriptionchange` event when the user visits a site with expired push
    141    // registrations. Visits only count if the user sees the origin in the address
    142    // bar. This excludes embedded resources, downloads, and framed links.
    143    const QUOTA_REFRESH_TRANSITIONS_SQL = [
    144      Ci.nsINavHistoryService.TRANSITION_LINK,
    145      Ci.nsINavHistoryService.TRANSITION_TYPED,
    146      Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
    147      Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
    148      Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY,
    149    ].join(",");
    150 
    151    let db = await lazy.PlacesUtils.promiseDBConnection();
    152    // We're using a custom query instead of `nsINavHistoryQueryOptions`
    153    // because the latter doesn't expose a way to filter by transition type:
    154    // `setTransitions` performs a logical "and," but we want an "or." We
    155    // also avoid an unneeded left join with favicons, and an `ORDER BY`
    156    // clause that emits a suboptimal index warning.
    157    let rows = await db.executeCached(
    158      `SELECT MAX(visit_date) AS lastVisit
    159       FROM moz_places p
    160       JOIN moz_historyvisits ON p.id = place_id
    161       WHERE rev_host = get_unreversed_host(:host || '.') || '.'
    162         AND url BETWEEN :prePath AND :prePath || X'FFFF'
    163         AND visit_type IN (${QUOTA_REFRESH_TRANSITIONS_SQL})
    164      `,
    165      {
    166        // Restrict the query to all pages for this origin.
    167        host: this.uri.host,
    168        prePath: this.uri.prePath,
    169      }
    170    );
    171 
    172    if (!rows.length) {
    173      return -Infinity;
    174    }
    175    // Places records times in microseconds.
    176    let lastVisit = rows[0].getResultByName("lastVisit");
    177 
    178    return lastVisit / 1000;
    179  },
    180 
    181  isTabOpen() {
    182    for (let window of Services.wm.getEnumerator("navigator:browser")) {
    183      if (window.closed || lazy.PrivateBrowsingUtils.isWindowPrivate(window)) {
    184        continue;
    185      }
    186      for (let tab of window.gBrowser.tabs) {
    187        let tabURI = tab.linkedBrowser.currentURI;
    188        if (tabURI.prePath == this.uri.prePath) {
    189          return true;
    190        }
    191      }
    192    }
    193    return false;
    194  },
    195 
    196  /**
    197   * Indicates whether the registration can deliver push messages to its
    198   * associated service worker. System subscriptions are exempt from the
    199   * permission check.
    200   */
    201  hasPermission() {
    202    if (
    203      this.systemRecord ||
    204      prefs.getBoolPref("testing.ignorePermission", false)
    205    ) {
    206      return true;
    207    }
    208    let permission = Services.perms.testExactPermissionFromPrincipal(
    209      this.principal,
    210      "desktop-notification"
    211    );
    212    return permission == Ci.nsIPermissionManager.ALLOW_ACTION;
    213  },
    214 
    215  quotaChanged() {
    216    if (!this.hasPermission()) {
    217      return Promise.resolve(false);
    218    }
    219    return this.getLastVisit().then(lastVisit => lastVisit > this.lastPush);
    220  },
    221 
    222  quotaApplies() {
    223    return !this.systemRecord;
    224  },
    225 
    226  isExpired() {
    227    return this.quota === 0;
    228  },
    229 
    230  matchesOriginAttributes(pattern) {
    231    if (this.systemRecord) {
    232      return false;
    233    }
    234    return ChromeUtils.originAttributesMatchPattern(
    235      this.principal.originAttributes,
    236      pattern
    237    );
    238  },
    239 
    240  hasAuthenticationSecret() {
    241    return (
    242      !!this.authenticationSecret && this.authenticationSecret.byteLength == 16
    243    );
    244  },
    245 
    246  matchesAppServerKey(key) {
    247    if (!this.appServerKey) {
    248      return !key;
    249    }
    250    if (!key) {
    251      return false;
    252    }
    253    return (
    254      this.appServerKey.length === key.length &&
    255      this.appServerKey.every((value, index) => value === key[index])
    256    );
    257  },
    258 
    259  toSubscription() {
    260    return {
    261      endpoint: this.pushEndpoint,
    262      lastPush: this.lastPush,
    263      pushCount: this.pushCount,
    264      p256dhKey: this.p256dhPublicKey,
    265      p256dhPrivateKey: this.p256dhPrivateKey,
    266      authenticationSecret: this.authenticationSecret,
    267      appServerKey: this.appServerKey,
    268      quota: this.quotaApplies() ? this.quota : -1,
    269      systemRecord: this.systemRecord,
    270    };
    271  },
    272 };
    273 
    274 // Define lazy getters for the principal and scope URI. IndexedDB can't store
    275 // `nsIPrincipal` objects, so we keep them in a private weak map.
    276 var principals = new WeakMap();
    277 Object.defineProperties(PushRecord.prototype, {
    278  principal: {
    279    get() {
    280      if (this.systemRecord) {
    281        return Services.scriptSecurityManager.getSystemPrincipal();
    282      }
    283      let principal = principals.get(this);
    284      if (!principal) {
    285        let uri = Services.io.newURI(this.scope);
    286        // Allow tests to omit origin attributes.
    287        let originSuffix = this.originAttributes || "";
    288        principal = Services.scriptSecurityManager.createContentPrincipal(
    289          uri,
    290          ChromeUtils.createOriginAttributesFromOrigin(originSuffix)
    291        );
    292        principals.set(this, principal);
    293      }
    294      return principal;
    295    },
    296    configurable: true,
    297  },
    298 
    299  uri: {
    300    get() {
    301      return this.principal.URI;
    302    },
    303    configurable: true,
    304  },
    305 });