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"use strict"; 6 7const { IndexedDBHelper } = ChromeUtils.import( 8 "resource://gre/modules/IndexedDBHelper.jsm" 9); 10const { XPCOMUtils } = ChromeUtils.import( 11 "resource://gre/modules/XPCOMUtils.jsm" 12); 13 14const EXPORTED_SYMBOLS = ["PushDB"]; 15 16XPCOMUtils.defineLazyGetter(this, "console", () => { 17 let { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm"); 18 return new ConsoleAPI({ 19 maxLogLevelPref: "dom.push.loglevel", 20 prefix: "PushDB", 21 }); 22}); 23 24function PushDB(dbName, dbVersion, dbStoreName, keyPath, model) { 25 console.debug("PushDB()"); 26 this._dbStoreName = dbStoreName; 27 this._keyPath = keyPath; 28 this._model = model; 29 30 // set the indexeddb database 31 this.initDBHelper(dbName, dbVersion, [dbStoreName]); 32} 33 34PushDB.prototype = { 35 __proto__: IndexedDBHelper.prototype, 36 37 toPushRecord(record) { 38 if (!record) { 39 return null; 40 } 41 return new this._model(record); 42 }, 43 44 isValidRecord(record) { 45 return ( 46 record && 47 typeof record.scope == "string" && 48 typeof record.originAttributes == "string" && 49 record.quota >= 0 && 50 typeof record[this._keyPath] == "string" 51 ); 52 }, 53 54 upgradeSchema(aTransaction, aDb, aOldVersion, aNewVersion) { 55 if (aOldVersion <= 3) { 56 // XXXnsm We haven't shipped Push during this upgrade, so I'm just going to throw old 57 // registrations away without even informing the app. 58 if (aDb.objectStoreNames.contains(this._dbStoreName)) { 59 aDb.deleteObjectStore(this._dbStoreName); 60 } 61 62 let objectStore = aDb.createObjectStore(this._dbStoreName, { 63 keyPath: this._keyPath, 64 }); 65 66 // index to fetch records based on endpoints. used by unregister 67 objectStore.createIndex("pushEndpoint", "pushEndpoint", { unique: true }); 68 69 // index to fetch records by identifiers. 70 // In the current security model, the originAttributes distinguish between 71 // different 'apps' on the same origin. Since ServiceWorkers are 72 // same-origin to the scope they are registered for, the attributes and 73 // scope are enough to reconstruct a valid principal. 74 objectStore.createIndex("identifiers", ["scope", "originAttributes"], { 75 unique: true, 76 }); 77 objectStore.createIndex("originAttributes", "originAttributes", { 78 unique: false, 79 }); 80 } 81 82 if (aOldVersion < 4) { 83 let objectStore = aTransaction.objectStore(this._dbStoreName); 84 85 // index to fetch active and expired registrations. 86 objectStore.createIndex("quota", "quota", { unique: false }); 87 } 88 }, 89 90 /* 91 * @param aRecord 92 * The record to be added. 93 */ 94 95 put(aRecord) { 96 console.debug("put()", aRecord); 97 if (!this.isValidRecord(aRecord)) { 98 return Promise.reject( 99 new TypeError( 100 "Scope, originAttributes, and quota are required! " + 101 JSON.stringify(aRecord) 102 ) 103 ); 104 } 105 106 return new Promise((resolve, reject) => 107 this.newTxn( 108 "readwrite", 109 this._dbStoreName, 110 (aTxn, aStore) => { 111 aTxn.result = undefined; 112 113 aStore.put(aRecord).onsuccess = aEvent => { 114 console.debug( 115 "put: Request successful. Updated record", 116 aEvent.target.result 117 ); 118 aTxn.result = this.toPushRecord(aRecord); 119 }; 120 }, 121 resolve, 122 reject 123 ) 124 ); 125 }, 126 127 /* 128 * @param aKeyID 129 * The ID of record to be deleted. 130 */ 131 delete(aKeyID) { 132 console.debug("delete()"); 133 134 return new Promise((resolve, reject) => 135 this.newTxn( 136 "readwrite", 137 this._dbStoreName, 138 (aTxn, aStore) => { 139 console.debug("delete: Removing record", aKeyID); 140 aStore.get(aKeyID).onsuccess = event => { 141 aTxn.result = this.toPushRecord(event.target.result); 142 aStore.delete(aKeyID); 143 }; 144 }, 145 resolve, 146 reject 147 ) 148 ); 149 }, 150 151 // testFn(record) is called with a database record and should return true if 152 // that record should be deleted. 153 clearIf(testFn) { 154 console.debug("clearIf()"); 155 return new Promise((resolve, reject) => 156 this.newTxn( 157 "readwrite", 158 this._dbStoreName, 159 (aTxn, aStore) => { 160 aTxn.result = undefined; 161 162 aStore.openCursor().onsuccess = event => { 163 let cursor = event.target.result; 164 if (cursor) { 165 let record = this.toPushRecord(cursor.value); 166 if (testFn(record)) { 167 let deleteRequest = cursor.delete(); 168 deleteRequest.onerror = e => { 169 console.error( 170 "clearIf: Error removing record", 171 record.keyID, 172 e 173 ); 174 }; 175 } 176 cursor.continue(); 177 } 178 }; 179 }, 180 resolve, 181 reject 182 ) 183 ); 184 }, 185 186 getByPushEndpoint(aPushEndpoint) { 187 console.debug("getByPushEndpoint()"); 188 189 return new Promise((resolve, reject) => 190 this.newTxn( 191 "readonly", 192 this._dbStoreName, 193 (aTxn, aStore) => { 194 aTxn.result = undefined; 195 196 let index = aStore.index("pushEndpoint"); 197 index.get(aPushEndpoint).onsuccess = aEvent => { 198 let record = this.toPushRecord(aEvent.target.result); 199 console.debug("getByPushEndpoint: Got record", record); 200 aTxn.result = record; 201 }; 202 }, 203 resolve, 204 reject 205 ) 206 ); 207 }, 208 209 getByKeyID(aKeyID) { 210 console.debug("getByKeyID()"); 211 212 return new Promise((resolve, reject) => 213 this.newTxn( 214 "readonly", 215 this._dbStoreName, 216 (aTxn, aStore) => { 217 aTxn.result = undefined; 218 219 aStore.get(aKeyID).onsuccess = aEvent => { 220 let record = this.toPushRecord(aEvent.target.result); 221 console.debug("getByKeyID: Got record", record); 222 aTxn.result = record; 223 }; 224 }, 225 resolve, 226 reject 227 ) 228 ); 229 }, 230 231 /** 232 * Iterates over all records associated with an origin. 233 * 234 * @param {String} origin The origin, matched as a prefix against the scope. 235 * @param {String} originAttributes Additional origin attributes. Requires 236 * an exact match. 237 * @param {Function} callback A function with the signature `(record, 238 * cursor)`, called for each record. `record` is the registration, and 239 * `cursor` is an `IDBCursor`. 240 * @returns {Promise} Resolves once all records have been processed. 241 */ 242 forEachOrigin(origin, originAttributes, callback) { 243 console.debug("forEachOrigin()"); 244 245 return new Promise((resolve, reject) => 246 this.newTxn( 247 "readwrite", 248 this._dbStoreName, 249 (aTxn, aStore) => { 250 aTxn.result = undefined; 251 252 let index = aStore.index("identifiers"); 253 let range = IDBKeyRange.bound( 254 [origin, originAttributes], 255 [origin + "\x7f", originAttributes] 256 ); 257 index.openCursor(range).onsuccess = event => { 258 let cursor = event.target.result; 259 if (!cursor) { 260 return; 261 } 262 callback(this.toPushRecord(cursor.value), cursor); 263 cursor.continue(); 264 }; 265 }, 266 resolve, 267 reject 268 ) 269 ); 270 }, 271 272 // Perform a unique match against { scope, originAttributes } 273 getByIdentifiers(aPageRecord) { 274 console.debug("getByIdentifiers()", aPageRecord); 275 if (!aPageRecord.scope || aPageRecord.originAttributes == undefined) { 276 console.error( 277 "getByIdentifiers: Scope and originAttributes are required", 278 aPageRecord 279 ); 280 return Promise.reject(new TypeError("Invalid page record")); 281 } 282 283 return new Promise((resolve, reject) => 284 this.newTxn( 285 "readonly", 286 this._dbStoreName, 287 (aTxn, aStore) => { 288 aTxn.result = undefined; 289 290 let index = aStore.index("identifiers"); 291 let request = index.get( 292 IDBKeyRange.only([aPageRecord.scope, aPageRecord.originAttributes]) 293 ); 294 request.onsuccess = aEvent => { 295 aTxn.result = this.toPushRecord(aEvent.target.result); 296 }; 297 }, 298 resolve, 299 reject 300 ) 301 ); 302 }, 303 304 _getAllByKey(aKeyName, aKeyValue) { 305 return new Promise((resolve, reject) => 306 this.newTxn( 307 "readonly", 308 this._dbStoreName, 309 (aTxn, aStore) => { 310 aTxn.result = undefined; 311 312 let index = aStore.index(aKeyName); 313 // It seems ok to use getAll here, since unlike contacts or other 314 // high storage APIs, we don't expect more than a handful of 315 // registrations per domain, and usually only one. 316 let getAllReq = index.mozGetAll(aKeyValue); 317 getAllReq.onsuccess = aEvent => { 318 aTxn.result = aEvent.target.result.map(record => 319 this.toPushRecord(record) 320 ); 321 }; 322 }, 323 resolve, 324 reject 325 ) 326 ); 327 }, 328 329 // aOriginAttributes must be a string! 330 getAllByOriginAttributes(aOriginAttributes) { 331 if (typeof aOriginAttributes !== "string") { 332 return Promise.reject("Expected string!"); 333 } 334 return this._getAllByKey("originAttributes", aOriginAttributes); 335 }, 336 337 getAllKeyIDs() { 338 console.debug("getAllKeyIDs()"); 339 340 return new Promise((resolve, reject) => 341 this.newTxn( 342 "readonly", 343 this._dbStoreName, 344 (aTxn, aStore) => { 345 aTxn.result = undefined; 346 aStore.mozGetAll().onsuccess = event => { 347 aTxn.result = event.target.result.map(record => 348 this.toPushRecord(record) 349 ); 350 }; 351 }, 352 resolve, 353 reject 354 ) 355 ); 356 }, 357 358 _getAllByPushQuota(range) { 359 console.debug("getAllByPushQuota()"); 360 361 return new Promise((resolve, reject) => 362 this.newTxn( 363 "readonly", 364 this._dbStoreName, 365 (aTxn, aStore) => { 366 aTxn.result = []; 367 368 let index = aStore.index("quota"); 369 index.openCursor(range).onsuccess = event => { 370 let cursor = event.target.result; 371 if (cursor) { 372 aTxn.result.push(this.toPushRecord(cursor.value)); 373 cursor.continue(); 374 } 375 }; 376 }, 377 resolve, 378 reject 379 ) 380 ); 381 }, 382 383 getAllUnexpired() { 384 console.debug("getAllUnexpired()"); 385 return this._getAllByPushQuota(IDBKeyRange.lowerBound(1)); 386 }, 387 388 getAllExpired() { 389 console.debug("getAllExpired()"); 390 return this._getAllByPushQuota(IDBKeyRange.only(0)); 391 }, 392 393 /** 394 * Updates an existing push registration. 395 * 396 * @param {String} aKeyID The registration ID. 397 * @param {Function} aUpdateFunc A function that receives the existing 398 * registration record as its argument, and returns a new record. 399 * @returns {Promise} A promise resolved with either the updated record. 400 * Rejects if the record does not exist, or the function returns an invalid 401 * record. 402 */ 403 update(aKeyID, aUpdateFunc) { 404 return new Promise((resolve, reject) => 405 this.newTxn( 406 "readwrite", 407 this._dbStoreName, 408 (aTxn, aStore) => { 409 aStore.get(aKeyID).onsuccess = aEvent => { 410 aTxn.result = undefined; 411 412 let record = aEvent.target.result; 413 if (!record) { 414 throw new Error("Record " + aKeyID + " does not exist"); 415 } 416 let newRecord = aUpdateFunc(this.toPushRecord(record)); 417 if (!this.isValidRecord(newRecord)) { 418 console.error( 419 "update: Ignoring invalid update", 420 aKeyID, 421 newRecord 422 ); 423 throw new Error("Invalid update for record " + aKeyID); 424 } 425 function putRecord() { 426 let req = aStore.put(newRecord); 427 req.onsuccess = aEvent => { 428 console.debug("update: Update successful", aKeyID, newRecord); 429 aTxn.result = newRecord; 430 }; 431 } 432 if (aKeyID === newRecord.keyID) { 433 putRecord(); 434 } else { 435 // If we changed the primary key, delete the old record to avoid 436 // unique constraint errors. 437 aStore.delete(aKeyID).onsuccess = putRecord; 438 } 439 }; 440 }, 441 resolve, 442 reject 443 ) 444 ); 445 }, 446 447 drop() { 448 console.debug("drop()"); 449 450 return new Promise((resolve, reject) => 451 this.newTxn( 452 "readwrite", 453 this._dbStoreName, 454 function txnCb(aTxn, aStore) { 455 aStore.clear(); 456 }, 457 resolve, 458 reject 459 ) 460 ); 461 }, 462}; 463