Blob Blame History Raw
/* 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/. */

"use strict";

ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");

ChromeUtils.defineModuleGetter(this, "EventDispatcher",
                               "resource://gre/modules/Messaging.jsm");

ChromeUtils.defineModuleGetter(this, "PlacesUtils",
                               "resource://gre/modules/PlacesUtils.jsm");
ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
                               "resource://gre/modules/PrivateBrowsingUtils.jsm");


var EXPORTED_SYMBOLS = ["PushRecord"];

const prefs = Services.prefs.getBranch("dom.push.");

/**
 * The push subscription record, stored in IndexedDB.
 */
function PushRecord(props) {
  this.pushEndpoint = props.pushEndpoint;
  this.scope = props.scope;
  this.originAttributes = props.originAttributes;
  this.pushCount = props.pushCount || 0;
  this.lastPush = props.lastPush || 0;
  this.p256dhPublicKey = props.p256dhPublicKey;
  this.p256dhPrivateKey = props.p256dhPrivateKey;
  this.authenticationSecret = props.authenticationSecret;
  this.systemRecord = !!props.systemRecord;
  this.appServerKey = props.appServerKey;
  this.recentMessageIDs = props.recentMessageIDs;
  this.setQuota(props.quota);
  this.ctime = (typeof props.ctime === "number") ? props.ctime : 0;
}

PushRecord.prototype = {
  setQuota(suggestedQuota) {
    if (this.quotaApplies()) {
      let quota = +suggestedQuota;
      this.quota = quota >= 0 ? quota : prefs.getIntPref("maxQuotaPerSubscription");
    } else {
      this.quota = Infinity;
    }
  },

  resetQuota() {
    this.quota = this.quotaApplies() ?
                 prefs.getIntPref("maxQuotaPerSubscription") : Infinity;
  },

  updateQuota(lastVisit) {
    if (this.isExpired() || !this.quotaApplies()) {
      // Ignore updates if the registration is already expired, or isn't
      // subject to quota.
      return;
    }
    if (lastVisit < 0) {
      // If the user cleared their history, but retained the push permission,
      // mark the registration as expired.
      this.quota = 0;
      return;
    }
    if (lastVisit > this.lastPush) {
      // If the user visited the site since the last time we received a
      // notification, reset the quota. `Math.max(0, ...)` ensures the
      // last visit date isn't in the future.
      let daysElapsed =
        Math.max(0, (Date.now() - lastVisit) / 24 / 60 / 60 / 1000);
      this.quota = Math.min(
        Math.round(8 * Math.pow(daysElapsed, -0.8)),
        prefs.getIntPref("maxQuotaPerSubscription")
      );
    }
  },

  receivedPush(lastVisit) {
    this.updateQuota(lastVisit);
    this.pushCount++;
    this.lastPush = Date.now();
  },

  /**
   * Records a message ID sent to this push registration. We track the last few
   * messages sent to each registration to avoid firing duplicate events for
   * unacknowledged messages.
   */
  noteRecentMessageID(id) {
    if (this.recentMessageIDs) {
      this.recentMessageIDs.unshift(id);
    } else {
      this.recentMessageIDs = [id];
    }
    // Drop older message IDs from the end of the list.
    let maxRecentMessageIDs = Math.min(
      this.recentMessageIDs.length,
      Math.max(prefs.getIntPref("maxRecentMessageIDsPerSubscription"), 0)
    );
    this.recentMessageIDs.length = maxRecentMessageIDs || 0;
  },

  hasRecentMessageID(id) {
    return this.recentMessageIDs && this.recentMessageIDs.includes(id);
  },

  reduceQuota() {
    if (!this.quotaApplies()) {
      return;
    }
    this.quota = Math.max(this.quota - 1, 0);
  },

  /**
   * Queries the Places database for the last time a user visited the site
   * associated with a push registration.
   *
   * @returns {Promise} A promise resolved with either the last time the user
   *  visited the site, or `-Infinity` if the site is not in the user's history.
   *  The time is expressed in milliseconds since Epoch.
   */
  async getLastVisit() {
    if (!this.quotaApplies() || this.isTabOpen()) {
      // If the registration isn't subject to quota, or the user already
      // has the site open, skip expensive database queries.
      return Date.now();
    }

    if (AppConstants.MOZ_ANDROID_HISTORY) {
      let result = await EventDispatcher.instance.sendRequestForResult({
        type: "History:GetPrePathLastVisitedTimeMilliseconds",
        prePath: this.uri.prePath,
      });
      return result == 0 ? -Infinity : result;
    }

    // Places History transition types that can fire a
    // `pushsubscriptionchange` event when the user visits a site with expired push
    // registrations. Visits only count if the user sees the origin in the address
    // bar. This excludes embedded resources, downloads, and framed links.
    const QUOTA_REFRESH_TRANSITIONS_SQL = [
      Ci.nsINavHistoryService.TRANSITION_LINK,
      Ci.nsINavHistoryService.TRANSITION_TYPED,
      Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
      Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
      Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY
    ].join(",");

    let db =  await PlacesUtils.promiseDBConnection();
    // We're using a custom query instead of `nsINavHistoryQueryOptions`
    // because the latter doesn't expose a way to filter by transition type:
    // `setTransitions` performs a logical "and," but we want an "or." We
    // also avoid an unneeded left join with favicons, and an `ORDER BY`
    // clause that emits a suboptimal index warning.
    let rows = await db.executeCached(
      `SELECT MAX(visit_date) AS lastVisit
       FROM moz_places p
       JOIN moz_historyvisits ON p.id = place_id
       WHERE rev_host = get_unreversed_host(:host || '.') || '.'
         AND url BETWEEN :prePath AND :prePath || X'FFFF'
         AND visit_type IN (${QUOTA_REFRESH_TRANSITIONS_SQL})
      `,
      {
        // Restrict the query to all pages for this origin.
        host: this.uri.host,
        prePath: this.uri.prePath,
      }
    );

    if (!rows.length) {
      return -Infinity;
    }
    // Places records times in microseconds.
    let lastVisit = rows[0].getResultByName("lastVisit");

    return lastVisit / 1000;
  },

  isTabOpen() {
    let windows = Services.wm.getEnumerator("navigator:browser");
    while (windows.hasMoreElements()) {
      let window = windows.getNext();
      if (window.closed || PrivateBrowsingUtils.isWindowPrivate(window)) {
        continue;
      }
      // `gBrowser` on Desktop; `BrowserApp` on Fennec.
      let tabs = window.gBrowser ? window.gBrowser.tabContainer.children :
                 window.BrowserApp.tabs;
      for (let tab of tabs) {
        // `linkedBrowser` on Desktop; `browser` on Fennec.
        let tabURI = (tab.linkedBrowser || tab.browser).currentURI;
        if (tabURI.prePath == this.uri.prePath) {
          return true;
        }
      }
    }
    return false;
  },

  /**
   * Indicates whether the registration can deliver push messages to its
   * associated service worker. System subscriptions are exempt from the
   * permission check.
   */
  hasPermission() {
    if (this.systemRecord || prefs.getBoolPref("testing.ignorePermission", false)) {
      return true;
    }
    let permission = Services.perms.testExactPermissionFromPrincipal(
      this.principal, "desktop-notification");
    return permission == Ci.nsIPermissionManager.ALLOW_ACTION;
  },

  quotaChanged() {
    if (!this.hasPermission()) {
      return Promise.resolve(false);
    }
    return this.getLastVisit()
      .then(lastVisit => lastVisit > this.lastPush);
  },

  quotaApplies() {
    return !this.systemRecord;
  },

  isExpired() {
    return this.quota === 0;
  },

  matchesOriginAttributes(pattern) {
    if (this.systemRecord) {
      return false;
    }
    return ChromeUtils.originAttributesMatchPattern(
      this.principal.originAttributes, pattern);
  },

  hasAuthenticationSecret() {
    return !!this.authenticationSecret &&
           this.authenticationSecret.byteLength == 16;
  },

  matchesAppServerKey(key) {
    if (!this.appServerKey) {
      return !key;
    }
    if (!key) {
      return false;
    }
    return this.appServerKey.length === key.length &&
           this.appServerKey.every((value, index) => value === key[index]);
  },

  toSubscription() {
    return {
      endpoint: this.pushEndpoint,
      lastPush: this.lastPush,
      pushCount: this.pushCount,
      p256dhKey: this.p256dhPublicKey,
      p256dhPrivateKey: this.p256dhPrivateKey,
      authenticationSecret: this.authenticationSecret,
      appServerKey: this.appServerKey,
      quota: this.quotaApplies() ? this.quota : -1,
      systemRecord: this.systemRecord,
    };
  },
};

// Define lazy getters for the principal and scope URI. IndexedDB can't store
// `nsIPrincipal` objects, so we keep them in a private weak map.
var principals = new WeakMap();
Object.defineProperties(PushRecord.prototype, {
  principal: {
    get() {
      if (this.systemRecord) {
        return Services.scriptSecurityManager.getSystemPrincipal();
      }
      let principal = principals.get(this);
      if (!principal) {
        let uri = Services.io.newURI(this.scope);
        // Allow tests to omit origin attributes.
        let originSuffix = this.originAttributes || "";
        let originAttributes =
        principal = Services.scriptSecurityManager.createCodebasePrincipal(uri,
          ChromeUtils.createOriginAttributesFromOrigin(originSuffix));
        principals.set(this, principal);
      }
      return principal;
    },
    configurable: true,
  },

  uri: {
    get() {
      return this.principal.URI;
    },
    configurable: true,
  },
});