Bug 1708193 - Remove mozapps/extensions/internal/Content.js r=rpl
[gecko.git] / dom / push / PushRecord.jsm
blob0793635e1519e5ebef0a920bc0d0f344cdc55396
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 "use strict";
7 const { AppConstants } = ChromeUtils.import(
8   "resource://gre/modules/AppConstants.jsm"
9 );
10 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
12 ChromeUtils.defineModuleGetter(
13   this,
14   "EventDispatcher",
15   "resource://gre/modules/Messaging.jsm"
18 ChromeUtils.defineModuleGetter(
19   this,
20   "PlacesUtils",
21   "resource://gre/modules/PlacesUtils.jsm"
23 ChromeUtils.defineModuleGetter(
24   this,
25   "PrivateBrowsingUtils",
26   "resource://gre/modules/PrivateBrowsingUtils.jsm"
29 const EXPORTED_SYMBOLS = ["PushRecord"];
31 const prefs = Services.prefs.getBranch("dom.push.");
33 /**
34  * The push subscription record, stored in IndexedDB.
35  */
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;
56       this.quota =
57         quota >= 0 ? quota : prefs.getIntPref("maxQuotaPerSubscription");
58     } else {
59       this.quota = Infinity;
60     }
61   },
63   resetQuota() {
64     this.quota = this.quotaApplies()
65       ? prefs.getIntPref("maxQuotaPerSubscription")
66       : Infinity;
67   },
69   updateQuota(lastVisit) {
70     if (this.isExpired() || !this.quotaApplies()) {
71       // Ignore updates if the registration is already expired, or isn't
72       // subject to quota.
73       return;
74     }
75     if (lastVisit < 0) {
76       // If the user cleared their history, but retained the push permission,
77       // mark the registration as expired.
78       this.quota = 0;
79       return;
80     }
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(
86         0,
87         (Date.now() - lastVisit) / 24 / 60 / 60 / 1000
88       );
89       this.quota = Math.min(
90         Math.round(8 * Math.pow(daysElapsed, -0.8)),
91         prefs.getIntPref("maxQuotaPerSubscription")
92       );
93     }
94   },
96   receivedPush(lastVisit) {
97     this.updateQuota(lastVisit);
98     this.pushCount++;
99     this.lastPush = Date.now();
100   },
102   /**
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.
106    */
107   noteRecentMessageID(id) {
108     if (this.recentMessageIDs) {
109       this.recentMessageIDs.unshift(id);
110     } else {
111       this.recentMessageIDs = [id];
112     }
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)
117     );
118     this.recentMessageIDs.length = maxRecentMessageIDs || 0;
119   },
121   hasRecentMessageID(id) {
122     return this.recentMessageIDs && this.recentMessageIDs.includes(id);
123   },
125   reduceQuota() {
126     if (!this.quotaApplies()) {
127       return;
128     }
129     this.quota = Math.max(this.quota - 1, 0);
130   },
132   /**
133    * Queries the Places database for the last time a user visited the site
134    * associated with a push registration.
135    *
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.
139    */
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.
144       return Date.now();
145     }
147     if (AppConstants.MOZ_ANDROID_HISTORY) {
148       let result = await EventDispatcher.instance.sendRequestForResult({
149         type: "History:GetPrePathLastVisitedTimeMilliseconds",
150         prePath: this.uri.prePath,
151       });
152       return result == 0 ? -Infinity : result;
153     }
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,
165     ].join(",");
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
175        FROM moz_places p
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})
180       `,
181       {
182         // Restrict the query to all pages for this origin.
183         host: this.uri.host,
184         prePath: this.uri.prePath,
185       }
186     );
188     if (!rows.length) {
189       return -Infinity;
190     }
191     // Places records times in microseconds.
192     let lastVisit = rows[0].getResultByName("lastVisit");
194     return lastVisit / 1000;
195   },
197   isTabOpen() {
198     for (let window of Services.wm.getEnumerator("navigator:browser")) {
199       if (window.closed || PrivateBrowsingUtils.isWindowPrivate(window)) {
200         continue;
201       }
202       for (let tab of window.gBrowser.tabs) {
203         let tabURI = tab.linkedBrowser.currentURI;
204         if (tabURI.prePath == this.uri.prePath) {
205           return true;
206         }
207       }
208     }
209     return false;
210   },
212   /**
213    * Indicates whether the registration can deliver push messages to its
214    * associated service worker. System subscriptions are exempt from the
215    * permission check.
216    */
217   hasPermission() {
218     if (
219       this.systemRecord ||
220       prefs.getBoolPref("testing.ignorePermission", false)
221     ) {
222       return true;
223     }
224     let permission = Services.perms.testExactPermissionFromPrincipal(
225       this.principal,
226       "desktop-notification"
227     );
228     return permission == Ci.nsIPermissionManager.ALLOW_ACTION;
229   },
231   quotaChanged() {
232     if (!this.hasPermission()) {
233       return Promise.resolve(false);
234     }
235     return this.getLastVisit().then(lastVisit => lastVisit > this.lastPush);
236   },
238   quotaApplies() {
239     return !this.systemRecord;
240   },
242   isExpired() {
243     return this.quota === 0;
244   },
246   matchesOriginAttributes(pattern) {
247     if (this.systemRecord) {
248       return false;
249     }
250     return ChromeUtils.originAttributesMatchPattern(
251       this.principal.originAttributes,
252       pattern
253     );
254   },
256   hasAuthenticationSecret() {
257     return (
258       !!this.authenticationSecret && this.authenticationSecret.byteLength == 16
259     );
260   },
262   matchesAppServerKey(key) {
263     if (!this.appServerKey) {
264       return !key;
265     }
266     if (!key) {
267       return false;
268     }
269     return (
270       this.appServerKey.length === key.length &&
271       this.appServerKey.every((value, index) => value === key[index])
272     );
273   },
275   toSubscription() {
276     return {
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,
286     };
287   },
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, {
294   principal: {
295     get() {
296       if (this.systemRecord) {
297         return Services.scriptSecurityManager.getSystemPrincipal();
298       }
299       let principal = principals.get(this);
300       if (!principal) {
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(
305           uri,
306           ChromeUtils.createOriginAttributesFromOrigin(originSuffix)
307         );
308         principals.set(this, principal);
309       }
310       return principal;
311     },
312     configurable: true,
313   },
315   uri: {
316     get() {
317       return this.principal.URI;
318     },
319     configurable: true,
320   },