gecko-dev/dom/push/PushRecord.jsm

310 lines
9.8 KiB
JavaScript
Raw Normal View History

/* 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/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "EventDispatcher",
"resource://gre/modules/Messaging.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm");
this.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
Bug 977177 - Move favicons to a separate store. r=adw This patch moves favicons blobs to a separate database names favicons.sqlite. The dabatase is then ATTACHED to the main Places connection, so that its tables can be used as if they were all part of the same database. The favicons.database contains 3 tables: 1. moz_pages_w_icons This is the way to join with moz_places, through page_url_hash and page_url. We are not using the place id to avoid possible mismatches between places.sqlite and favicons.sqlite. This way the database is "portable" and reusable even if places.sqlite changes. 2. moz_icons Contains icons payloads, each payload can either be an SVG or a PNG. These are the only stored formats, any other format is rescaled and converted to PNG. ICO files are split into single frames and stored as multiple PNGs. SVG are distinguishable through width == UINT16_MAX In future the table will also contain mask-icon color for SVG and average color for PNGs. The fixed_icon_url_hash is "fixed" to allow quickly fetch root icons, that means icons like "domain/favicon.ico" that can also be reused for any page under that domain. 3. moz_icons_to_pages This is the relation table between icons and pages. Each page can have multiple icons, each icon can be used by multiple pages. There is a FOREIGN_KEY constraint between this (child) table and icons or pages (parents), so that it's not possible to insert non-existing ids in this table, and if an entry is removed from a parent table, the relation will be automatically removed from here. Note though that removing from the relation table won't remove from the parent tables. Since the relations are now many-many, it's no more possible to simply join places with the icons table and obtain a single icon, thus it's suggested that consumers go through the "page-icon" protocol. The migration process from the old favicons table is async and interruptible, it will be restarted along with the favicons service until the temp preference places.favicons.convertPayloads is set to true. MozReview-Commit-ID: CUCoL9smRyt --HG-- extra : rebase_source : 4d25966596dcdf63c9c872425c5bf147406d25ac
2016-11-14 15:22:46 +00:00
// 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,
},
});