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/. */
7 const { AppConstants } = ChromeUtils.import(
8 "resource://gre/modules/AppConstants.jsm"
10 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
12 ChromeUtils.defineModuleGetter(
15 "resource://gre/modules/Messaging.jsm"
18 ChromeUtils.defineModuleGetter(
21 "resource://gre/modules/PlacesUtils.jsm"
23 ChromeUtils.defineModuleGetter(
25 "PrivateBrowsingUtils",
26 "resource://gre/modules/PrivateBrowsingUtils.jsm"
29 const EXPORTED_SYMBOLS = ["PushRecord"];
31 const prefs = Services.prefs.getBranch("dom.push.");
34 * The push subscription record, stored in IndexedDB.
36 function PushRecord(props) {
37 this.pushEndpoint = props.pushEndpoint;
38 this.scope = props.scope;
39 this.originAttributes = props.originAttributes;
40 this.pushCount = props.pushCount || 0;
41 this.lastPush = props.lastPush || 0;
42 this.p256dhPublicKey = props.p256dhPublicKey;
43 this.p256dhPrivateKey = props.p256dhPrivateKey;
44 this.authenticationSecret = props.authenticationSecret;
45 this.systemRecord = !!props.systemRecord;
46 this.appServerKey = props.appServerKey;
47 this.recentMessageIDs = props.recentMessageIDs;
48 this.setQuota(props.quota);
49 this.ctime = typeof props.ctime === "number" ? props.ctime : 0;
52 PushRecord.prototype = {
53 setQuota(suggestedQuota) {
54 if (this.quotaApplies()) {
55 let quota = +suggestedQuota;
57 quota >= 0 ? quota : prefs.getIntPref("maxQuotaPerSubscription");
59 this.quota = Infinity;
64 this.quota = this.quotaApplies()
65 ? prefs.getIntPref("maxQuotaPerSubscription")
69 updateQuota(lastVisit) {
70 if (this.isExpired() || !this.quotaApplies()) {
71 // Ignore updates if the registration is already expired, or isn't
76 // If the user cleared their history, but retained the push permission,
77 // mark the registration as expired.
81 if (lastVisit > this.lastPush) {
82 // If the user visited the site since the last time we received a
83 // notification, reset the quota. `Math.max(0, ...)` ensures the
84 // last visit date isn't in the future.
85 let daysElapsed = Math.max(
87 (Date.now() - lastVisit) / 24 / 60 / 60 / 1000
89 this.quota = Math.min(
90 Math.round(8 * Math.pow(daysElapsed, -0.8)),
91 prefs.getIntPref("maxQuotaPerSubscription")
96 receivedPush(lastVisit) {
97 this.updateQuota(lastVisit);
99 this.lastPush = Date.now();
103 * Records a message ID sent to this push registration. We track the last few
104 * messages sent to each registration to avoid firing duplicate events for
105 * unacknowledged messages.
107 noteRecentMessageID(id) {
108 if (this.recentMessageIDs) {
109 this.recentMessageIDs.unshift(id);
111 this.recentMessageIDs = [id];
113 // Drop older message IDs from the end of the list.
114 let maxRecentMessageIDs = Math.min(
115 this.recentMessageIDs.length,
116 Math.max(prefs.getIntPref("maxRecentMessageIDsPerSubscription"), 0)
118 this.recentMessageIDs.length = maxRecentMessageIDs || 0;
121 hasRecentMessageID(id) {
122 return this.recentMessageIDs && this.recentMessageIDs.includes(id);
126 if (!this.quotaApplies()) {
129 this.quota = Math.max(this.quota - 1, 0);
133 * Queries the Places database for the last time a user visited the site
134 * associated with a push registration.
136 * @returns {Promise} A promise resolved with either the last time the user
137 * visited the site, or `-Infinity` if the site is not in the user's history.
138 * The time is expressed in milliseconds since Epoch.
140 async getLastVisit() {
141 if (!this.quotaApplies() || this.isTabOpen()) {
142 // If the registration isn't subject to quota, or the user already
143 // has the site open, skip expensive database queries.
147 if (AppConstants.MOZ_ANDROID_HISTORY) {
148 let result = await EventDispatcher.instance.sendRequestForResult({
149 type: "History:GetPrePathLastVisitedTimeMilliseconds",
150 prePath: this.uri.prePath,
152 return result == 0 ? -Infinity : result;
155 // Places History transition types that can fire a
156 // `pushsubscriptionchange` event when the user visits a site with expired push
157 // registrations. Visits only count if the user sees the origin in the address
158 // bar. This excludes embedded resources, downloads, and framed links.
159 const QUOTA_REFRESH_TRANSITIONS_SQL = [
160 Ci.nsINavHistoryService.TRANSITION_LINK,
161 Ci.nsINavHistoryService.TRANSITION_TYPED,
162 Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
163 Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
164 Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY,
167 let db = await PlacesUtils.promiseDBConnection();
168 // We're using a custom query instead of `nsINavHistoryQueryOptions`
169 // because the latter doesn't expose a way to filter by transition type:
170 // `setTransitions` performs a logical "and," but we want an "or." We
171 // also avoid an unneeded left join with favicons, and an `ORDER BY`
172 // clause that emits a suboptimal index warning.
173 let rows = await db.executeCached(
174 `SELECT MAX(visit_date) AS lastVisit
176 JOIN moz_historyvisits ON p.id = place_id
177 WHERE rev_host = get_unreversed_host(:host || '.') || '.'
178 AND url BETWEEN :prePath AND :prePath || X'FFFF'
179 AND visit_type IN (${QUOTA_REFRESH_TRANSITIONS_SQL})
182 // Restrict the query to all pages for this origin.
184 prePath: this.uri.prePath,
191 // Places records times in microseconds.
192 let lastVisit = rows[0].getResultByName("lastVisit");
194 return lastVisit / 1000;
198 for (let window of Services.wm.getEnumerator("navigator:browser")) {
199 if (window.closed || PrivateBrowsingUtils.isWindowPrivate(window)) {
202 for (let tab of window.gBrowser.tabs) {
203 let tabURI = tab.linkedBrowser.currentURI;
204 if (tabURI.prePath == this.uri.prePath) {
213 * Indicates whether the registration can deliver push messages to its
214 * associated service worker. System subscriptions are exempt from the
220 prefs.getBoolPref("testing.ignorePermission", false)
224 let permission = Services.perms.testExactPermissionFromPrincipal(
226 "desktop-notification"
228 return permission == Ci.nsIPermissionManager.ALLOW_ACTION;
232 if (!this.hasPermission()) {
233 return Promise.resolve(false);
235 return this.getLastVisit().then(lastVisit => lastVisit > this.lastPush);
239 return !this.systemRecord;
243 return this.quota === 0;
246 matchesOriginAttributes(pattern) {
247 if (this.systemRecord) {
250 return ChromeUtils.originAttributesMatchPattern(
251 this.principal.originAttributes,
256 hasAuthenticationSecret() {
258 !!this.authenticationSecret && this.authenticationSecret.byteLength == 16
262 matchesAppServerKey(key) {
263 if (!this.appServerKey) {
270 this.appServerKey.length === key.length &&
271 this.appServerKey.every((value, index) => value === key[index])
277 endpoint: this.pushEndpoint,
278 lastPush: this.lastPush,
279 pushCount: this.pushCount,
280 p256dhKey: this.p256dhPublicKey,
281 p256dhPrivateKey: this.p256dhPrivateKey,
282 authenticationSecret: this.authenticationSecret,
283 appServerKey: this.appServerKey,
284 quota: this.quotaApplies() ? this.quota : -1,
285 systemRecord: this.systemRecord,
290 // Define lazy getters for the principal and scope URI. IndexedDB can't store
291 // `nsIPrincipal` objects, so we keep them in a private weak map.
292 var principals = new WeakMap();
293 Object.defineProperties(PushRecord.prototype, {
296 if (this.systemRecord) {
297 return Services.scriptSecurityManager.getSystemPrincipal();
299 let principal = principals.get(this);
301 let uri = Services.io.newURI(this.scope);
302 // Allow tests to omit origin attributes.
303 let originSuffix = this.originAttributes || "";
304 principal = Services.scriptSecurityManager.createContentPrincipal(
306 ChromeUtils.createOriginAttributesFromOrigin(originSuffix)
308 principals.set(this, principal);
317 return this.principal.URI;