no bug - Bumping Firefox l10n changesets r=release a=l10n-bump DONTBUILD CLOSED TREE
[gecko.git] / dom / push / PushRecord.sys.mjs
blobaa69a2b22cad7d75ce8776a485eadd57f51f6868
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";
7 const lazy = {};
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",
13 });
15 const prefs = Services.prefs.getBranch("dom.push.");
17 /**
18  * The push subscription record, stored in IndexedDB.
19  */
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;
40       this.quota =
41         quota >= 0 ? quota : prefs.getIntPref("maxQuotaPerSubscription");
42     } else {
43       this.quota = Infinity;
44     }
45   },
47   resetQuota() {
48     this.quota = this.quotaApplies()
49       ? prefs.getIntPref("maxQuotaPerSubscription")
50       : Infinity;
51   },
53   updateQuota(lastVisit) {
54     if (this.isExpired() || !this.quotaApplies()) {
55       // Ignore updates if the registration is already expired, or isn't
56       // subject to quota.
57       return;
58     }
59     if (lastVisit < 0) {
60       // If the user cleared their history, but retained the push permission,
61       // mark the registration as expired.
62       this.quota = 0;
63       return;
64     }
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(
70         0,
71         (Date.now() - lastVisit) / 24 / 60 / 60 / 1000
72       );
73       this.quota = Math.min(
74         Math.round(8 * Math.pow(daysElapsed, -0.8)),
75         prefs.getIntPref("maxQuotaPerSubscription")
76       );
77     }
78   },
80   receivedPush(lastVisit) {
81     this.updateQuota(lastVisit);
82     this.pushCount++;
83     this.lastPush = Date.now();
84   },
86   /**
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.
90    */
91   noteRecentMessageID(id) {
92     if (this.recentMessageIDs) {
93       this.recentMessageIDs.unshift(id);
94     } else {
95       this.recentMessageIDs = [id];
96     }
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)
101     );
102     this.recentMessageIDs.length = maxRecentMessageIDs || 0;
103   },
105   hasRecentMessageID(id) {
106     return this.recentMessageIDs && this.recentMessageIDs.includes(id);
107   },
109   reduceQuota() {
110     if (!this.quotaApplies()) {
111       return;
112     }
113     this.quota = Math.max(this.quota - 1, 0);
114   },
116   /**
117    * Queries the Places database for the last time a user visited the site
118    * associated with a push registration.
119    *
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.
123    */
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.
128       return Date.now();
129     }
131     if (AppConstants.MOZ_ANDROID_HISTORY) {
132       let result = await lazy.EventDispatcher.instance.sendRequestForResult({
133         type: "History:GetPrePathLastVisitedTimeMilliseconds",
134         prePath: this.uri.prePath,
135       });
136       return result == 0 ? -Infinity : result;
137     }
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,
149     ].join(",");
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
159        FROM moz_places p
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})
164       `,
165       {
166         // Restrict the query to all pages for this origin.
167         host: this.uri.host,
168         prePath: this.uri.prePath,
169       }
170     );
172     if (!rows.length) {
173       return -Infinity;
174     }
175     // Places records times in microseconds.
176     let lastVisit = rows[0].getResultByName("lastVisit");
178     return lastVisit / 1000;
179   },
181   isTabOpen() {
182     for (let window of Services.wm.getEnumerator("navigator:browser")) {
183       if (window.closed || lazy.PrivateBrowsingUtils.isWindowPrivate(window)) {
184         continue;
185       }
186       for (let tab of window.gBrowser.tabs) {
187         let tabURI = tab.linkedBrowser.currentURI;
188         if (tabURI.prePath == this.uri.prePath) {
189           return true;
190         }
191       }
192     }
193     return false;
194   },
196   /**
197    * Indicates whether the registration can deliver push messages to its
198    * associated service worker. System subscriptions are exempt from the
199    * permission check.
200    */
201   hasPermission() {
202     if (
203       this.systemRecord ||
204       prefs.getBoolPref("testing.ignorePermission", false)
205     ) {
206       return true;
207     }
208     let permission = Services.perms.testExactPermissionFromPrincipal(
209       this.principal,
210       "desktop-notification"
211     );
212     return permission == Ci.nsIPermissionManager.ALLOW_ACTION;
213   },
215   quotaChanged() {
216     if (!this.hasPermission()) {
217       return Promise.resolve(false);
218     }
219     return this.getLastVisit().then(lastVisit => lastVisit > this.lastPush);
220   },
222   quotaApplies() {
223     return !this.systemRecord;
224   },
226   isExpired() {
227     return this.quota === 0;
228   },
230   matchesOriginAttributes(pattern) {
231     if (this.systemRecord) {
232       return false;
233     }
234     return ChromeUtils.originAttributesMatchPattern(
235       this.principal.originAttributes,
236       pattern
237     );
238   },
240   hasAuthenticationSecret() {
241     return (
242       !!this.authenticationSecret && this.authenticationSecret.byteLength == 16
243     );
244   },
246   matchesAppServerKey(key) {
247     if (!this.appServerKey) {
248       return !key;
249     }
250     if (!key) {
251       return false;
252     }
253     return (
254       this.appServerKey.length === key.length &&
255       this.appServerKey.every((value, index) => value === key[index])
256     );
257   },
259   toSubscription() {
260     return {
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,
270     };
271   },
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, {
278   principal: {
279     get() {
280       if (this.systemRecord) {
281         return Services.scriptSecurityManager.getSystemPrincipal();
282       }
283       let principal = principals.get(this);
284       if (!principal) {
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(
289           uri,
290           ChromeUtils.createOriginAttributesFromOrigin(originSuffix)
291         );
292         principals.set(this, principal);
293       }
294       return principal;
295     },
296     configurable: true,
297   },
299   uri: {
300     get() {
301       return this.principal.URI;
302     },
303     configurable: true,
304   },