diff options
Diffstat (limited to 'dom/push/PushRecord.sys.mjs')
-rw-r--r-- | dom/push/PushRecord.sys.mjs | 305 |
1 files changed, 305 insertions, 0 deletions
diff --git a/dom/push/PushRecord.sys.mjs b/dom/push/PushRecord.sys.mjs new file mode 100644 index 0000000000..aa69a2b22c --- /dev/null +++ b/dom/push/PushRecord.sys.mjs @@ -0,0 +1,305 @@ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +const prefs = Services.prefs.getBranch("dom.push."); + +/** + * The push subscription record, stored in IndexedDB. + */ +export 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 lazy.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 lazy.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() { + for (let window of Services.wm.getEnumerator("navigator:browser")) { + if (window.closed || lazy.PrivateBrowsingUtils.isWindowPrivate(window)) { + continue; + } + for (let tab of window.gBrowser.tabs) { + let tabURI = tab.linkedBrowser.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 || ""; + principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + ChromeUtils.createOriginAttributesFromOrigin(originSuffix) + ); + principals.set(this, principal); + } + return principal; + }, + configurable: true, + }, + + uri: { + get() { + return this.principal.URI; + }, + configurable: true, + }, +}); |