gecko-dev/dom/push/PushRecord.jsm

319 lines
10 KiB
JavaScript

/* 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";
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;
Cu.import("resource://gre/modules/AppConstants.jsm");
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Messaging",
"resource://gre/modules/Messaging.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm");
this.EXPORTED_SYMBOLS = ["PushRecord"];
const prefs = new Preferences("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.get("maxQuotaPerSubscription");
} else {
this.quota = Infinity;
}
},
resetQuota() {
this.quota = this.quotaApplies() ?
prefs.get("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.get("maxQuotaPerSubscription")
);
Services.telemetry.getHistogramById("PUSH_API_QUOTA_RESET_TO").add(this.quota);
}
},
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.get("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);
// We check for ctime > 0 to skip older records that did not have ctime.
if (this.isExpired() && this.ctime > 0) {
let duration = Date.now() - this.ctime;
Services.telemetry.getHistogramById("PUSH_API_QUOTA_EXPIRATION_TIME").add(duration / 1000);
}
},
/**
* 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.
*/
getLastVisit: Task.async(function* () {
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 = yield Messaging.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 = yield 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 on `moz_favicons`, and an `ORDER BY`
// clause that emits a suboptimal index warning.
let rows = yield 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.get("testing.ignorePermission")) {
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,
},
});