PushRecord.sys.mjs (9376B)
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/. */ 4 5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 6 7 const lazy = {}; 8 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 }); 14 15 const prefs = Services.prefs.getBranch("dom.push."); 16 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; 34 } 35 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 }, 46 47 resetQuota() { 48 this.quota = this.quotaApplies() 49 ? prefs.getIntPref("maxQuotaPerSubscription") 50 : Infinity; 51 }, 52 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 }, 79 80 receivedPush(lastVisit) { 81 this.updateQuota(lastVisit); 82 this.pushCount++; 83 this.lastPush = Date.now(); 84 }, 85 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 }, 104 105 hasRecentMessageID(id) { 106 return this.recentMessageIDs && this.recentMessageIDs.includes(id); 107 }, 108 109 reduceQuota() { 110 if (!this.quotaApplies()) { 111 return; 112 } 113 this.quota = Math.max(this.quota - 1, 0); 114 }, 115 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 } 130 131 if (AppConstants.MOZ_GECKOVIEW_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 } 138 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(","); 150 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 ); 171 172 if (!rows.length) { 173 return -Infinity; 174 } 175 // Places records times in microseconds. 176 let lastVisit = rows[0].getResultByName("lastVisit"); 177 178 return lastVisit / 1000; 179 }, 180 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 }, 195 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 }, 214 215 quotaChanged() { 216 if (!this.hasPermission()) { 217 return Promise.resolve(false); 218 } 219 return this.getLastVisit().then(lastVisit => lastVisit > this.lastPush); 220 }, 221 222 quotaApplies() { 223 return !this.systemRecord; 224 }, 225 226 isExpired() { 227 return this.quota === 0; 228 }, 229 230 matchesOriginAttributes(pattern) { 231 if (this.systemRecord) { 232 return false; 233 } 234 return ChromeUtils.originAttributesMatchPattern( 235 this.principal.originAttributes, 236 pattern 237 ); 238 }, 239 240 hasAuthenticationSecret() { 241 return ( 242 !!this.authenticationSecret && this.authenticationSecret.byteLength == 16 243 ); 244 }, 245 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 }, 258 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 }, 272 }; 273 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 }, 298 299 uri: { 300 get() { 301 return this.principal.URI; 302 }, 303 configurable: true, 304 }, 305 });