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/. */
5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
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",
15 const prefs = Services.prefs.getBranch("dom.push.");
18 * The push subscription record, stored in IndexedDB.
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;
36 PushRecord.prototype = {
37 setQuota(suggestedQuota) {
38 if (this.quotaApplies()) {
39 let quota = +suggestedQuota;
41 quota >= 0 ? quota : prefs.getIntPref("maxQuotaPerSubscription");
43 this.quota = Infinity;
48 this.quota = this.quotaApplies()
49 ? prefs.getIntPref("maxQuotaPerSubscription")
53 updateQuota(lastVisit) {
54 if (this.isExpired() || !this.quotaApplies()) {
55 // Ignore updates if the registration is already expired, or isn't
60 // If the user cleared their history, but retained the push permission,
61 // mark the registration as expired.
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(
71 (Date.now() - lastVisit) / 24 / 60 / 60 / 1000
73 this.quota = Math.min(
74 Math.round(8 * Math.pow(daysElapsed, -0.8)),
75 prefs.getIntPref("maxQuotaPerSubscription")
80 receivedPush(lastVisit) {
81 this.updateQuota(lastVisit);
83 this.lastPush = Date.now();
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.
91 noteRecentMessageID(id) {
92 if (this.recentMessageIDs) {
93 this.recentMessageIDs.unshift(id);
95 this.recentMessageIDs = [id];
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)
102 this.recentMessageIDs.length = maxRecentMessageIDs || 0;
105 hasRecentMessageID(id) {
106 return this.recentMessageIDs && this.recentMessageIDs.includes(id);
110 if (!this.quotaApplies()) {
113 this.quota = Math.max(this.quota - 1, 0);
117 * Queries the Places database for the last time a user visited the site
118 * associated with a push registration.
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.
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.
131 if (AppConstants.MOZ_ANDROID_HISTORY) {
132 let result = await lazy.EventDispatcher.instance.sendRequestForResult({
133 type: "History:GetPrePathLastVisitedTimeMilliseconds",
134 prePath: this.uri.prePath,
136 return result == 0 ? -Infinity : result;
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,
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
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})
166 // Restrict the query to all pages for this origin.
168 prePath: this.uri.prePath,
175 // Places records times in microseconds.
176 let lastVisit = rows[0].getResultByName("lastVisit");
178 return lastVisit / 1000;
182 for (let window of Services.wm.getEnumerator("navigator:browser")) {
183 if (window.closed || lazy.PrivateBrowsingUtils.isWindowPrivate(window)) {
186 for (let tab of window.gBrowser.tabs) {
187 let tabURI = tab.linkedBrowser.currentURI;
188 if (tabURI.prePath == this.uri.prePath) {
197 * Indicates whether the registration can deliver push messages to its
198 * associated service worker. System subscriptions are exempt from the
204 prefs.getBoolPref("testing.ignorePermission", false)
208 let permission = Services.perms.testExactPermissionFromPrincipal(
210 "desktop-notification"
212 return permission == Ci.nsIPermissionManager.ALLOW_ACTION;
216 if (!this.hasPermission()) {
217 return Promise.resolve(false);
219 return this.getLastVisit().then(lastVisit => lastVisit > this.lastPush);
223 return !this.systemRecord;
227 return this.quota === 0;
230 matchesOriginAttributes(pattern) {
231 if (this.systemRecord) {
234 return ChromeUtils.originAttributesMatchPattern(
235 this.principal.originAttributes,
240 hasAuthenticationSecret() {
242 !!this.authenticationSecret && this.authenticationSecret.byteLength == 16
246 matchesAppServerKey(key) {
247 if (!this.appServerKey) {
254 this.appServerKey.length === key.length &&
255 this.appServerKey.every((value, index) => value === key[index])
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,
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, {
280 if (this.systemRecord) {
281 return Services.scriptSecurityManager.getSystemPrincipal();
283 let principal = principals.get(this);
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(
290 ChromeUtils.createOriginAttributesFromOrigin(originSuffix)
292 principals.set(this, principal);
301 return this.principal.URI;