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