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
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4"use strict";
5
6/**
7 * FormHistory
8 *
9 * Used to store values that have been entered into forms which may later
10 * be used to automatically fill in the values when the form is visited again.
11 *
12 * search(terms, queryData, callback)
13 *   Look up values that have been previously stored.
14 *     terms - array of terms to return data for
15 *     queryData - object that contains the query terms
16 *       The query object contains properties for each search criteria to match, where the value
17 *       of the property specifies the value that term must have. For example,
18 *       { term1: value1, term2: value2 }
19 *     callback - callback that is called when results are available or an error occurs.
20 *       The callback is passed a result array containing each found entry. Each element in
21 *       the array is an object containing a property for each search term specified by 'terms'.
22 * count(queryData, callback)
23 *   Find the number of stored entries that match the given criteria.
24 *     queryData - array of objects that indicate the query. See the search method for details.
25 *     callback - callback that is called when results are available or an error occurs.
26 *       The callback is passed the number of found entries.
27 * update(changes, callback)
28 *    Write data to form history storage.
29 *      changes - an array of changes to be made. If only one change is to be made, it
30 *                may be passed as an object rather than a one-element array.
31 *        Each change object is of the form:
32 *          { op: operation, term1: value1, term2: value2, ... }
33 *        Valid operations are:
34 *          add - add a new entry
35 *          update - update an existing entry
36 *          remove - remove an entry
37 *          bump - update the last accessed time on an entry
38 *        The terms specified allow matching of one or more specific entries. If no terms
39 *        are specified then all entries are matched. This means that { op: "remove" } is
40 *        used to remove all entries and clear the form history.
41 *      callback - callback that is called when results have been stored.
42 * getAutoCompeteResults(searchString, params, callback)
43 *   Retrieve an array of form history values suitable for display in an autocomplete list.
44 *   Returns an mozIStoragePendingStatement that can be used to cancel the operation if
45 *   needed.
46 *     searchString - the string to search for, typically the entered value of a textbox
47 *     params - zero or more filter arguments:
48 *       fieldname - form field name
49 *       agedWeight
50 *       bucketSize
51 *       expiryDate
52 *       maxTimeGroundings
53 *       timeGroupingSize
54 *       prefixWeight
55 *       boundaryWeight
56 *     callback - callback that is called with the array of results. Each result in the array
57 *                is an object with four arguments:
58 *                  text, textLowerCase, frecency, totalScore
59 * schemaVersion
60 *   This property holds the version of the database schema
61 *
62 * Terms:
63 *  guid - entry identifier. For 'add', a guid will be generated.
64 *  fieldname - form field name
65 *  value - form value
66 *  timesUsed - the number of times the entry has been accessed
67 *  firstUsed - the time the the entry was first created
68 *  lastUsed - the time the entry was last accessed
69 *  firstUsedStart - search for entries created after or at this time
70 *  firstUsedEnd - search for entries created before or at this time
71 *  lastUsedStart - search for entries last accessed after or at this time
72 *  lastUsedEnd - search for entries last accessed before or at this time
73 *  newGuid - a special case valid only for 'update' and allows the guid for
74 *            an existing record to be updated. The 'guid' term is the only
75 *            other term which can be used (ie, you can not also specify a
76 *            fieldname, value etc) and indicates the guid of the existing
77 *            record that should be updated.
78 *
79 * In all of the above methods, the callback argument should be an object with
80 * handleResult(result), handleFailure(error) and handleCompletion(reason) functions.
81 * For search and getAutoCompeteResults, result is an object containing the desired
82 * properties. For count, result is the integer count. For, update, handleResult is
83 * not called. For handleCompletion, reason is either 0 if successful or 1 if
84 * an error occurred.
85 */
86
87var EXPORTED_SYMBOLS = ["FormHistory"];
88
89const { XPCOMUtils } = ChromeUtils.import(
90  "resource://gre/modules/XPCOMUtils.jsm"
91);
92const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
93const { AppConstants } = ChromeUtils.import(
94  "resource://gre/modules/AppConstants.jsm"
95);
96
97XPCOMUtils.defineLazyServiceGetter(
98  this,
99  "uuidService",
100  "@mozilla.org/uuid-generator;1",
101  "nsIUUIDGenerator"
102);
103ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
104ChromeUtils.defineModuleGetter(
105  this,
106  "Sqlite",
107  "resource://gre/modules/Sqlite.jsm"
108);
109
110const DB_SCHEMA_VERSION = 4;
111const DAY_IN_MS = 86400000; // 1 day in milliseconds
112const MAX_SEARCH_TOKENS = 10;
113const NOOP = function noop() {};
114const DB_FILENAME = "formhistory.sqlite";
115
116var supportsDeletedTable = AppConstants.platform == "android";
117
118var Prefs = {
119  initialized: false,
120
121  get debug() {
122    this.ensureInitialized();
123    return this._debug;
124  },
125  get enabled() {
126    this.ensureInitialized();
127    return this._enabled;
128  },
129  get expireDays() {
130    this.ensureInitialized();
131    return this._expireDays;
132  },
133
134  ensureInitialized() {
135    if (this.initialized) {
136      return;
137    }
138
139    this.initialized = true;
140
141    this._debug = Services.prefs.getBoolPref("browser.formfill.debug");
142    this._enabled = Services.prefs.getBoolPref("browser.formfill.enable");
143    this._expireDays = Services.prefs.getIntPref(
144      "browser.formfill.expire_days"
145    );
146  },
147};
148
149function log(aMessage) {
150  if (Prefs.debug) {
151    Services.console.logStringMessage("FormHistory: " + aMessage);
152  }
153}
154
155function sendNotification(aType, aData) {
156  if (typeof aData == "string") {
157    let strWrapper = Cc["@mozilla.org/supports-string;1"].createInstance(
158      Ci.nsISupportsString
159    );
160    strWrapper.data = aData;
161    aData = strWrapper;
162  } else if (typeof aData == "number") {
163    let intWrapper = Cc["@mozilla.org/supports-PRInt64;1"].createInstance(
164      Ci.nsISupportsPRInt64
165    );
166    intWrapper.data = aData;
167    aData = intWrapper;
168  } else if (aData) {
169    throw Components.Exception(
170      "Invalid type " + typeof aType + " passed to sendNotification",
171      Cr.NS_ERROR_ILLEGAL_VALUE
172    );
173  }
174
175  Services.obs.notifyObservers(aData, "satchel-storage-changed", aType);
176}
177
178/**
179 * Current database schema
180 */
181
182const dbSchema = {
183  tables: {
184    moz_formhistory: {
185      id: "INTEGER PRIMARY KEY",
186      fieldname: "TEXT NOT NULL",
187      value: "TEXT NOT NULL",
188      timesUsed: "INTEGER",
189      firstUsed: "INTEGER",
190      lastUsed: "INTEGER",
191      guid: "TEXT",
192    },
193    moz_deleted_formhistory: {
194      id: "INTEGER PRIMARY KEY",
195      timeDeleted: "INTEGER",
196      guid: "TEXT",
197    },
198  },
199  indices: {
200    moz_formhistory_index: {
201      table: "moz_formhistory",
202      columns: ["fieldname"],
203    },
204    moz_formhistory_lastused_index: {
205      table: "moz_formhistory",
206      columns: ["lastUsed"],
207    },
208    moz_formhistory_guid_index: {
209      table: "moz_formhistory",
210      columns: ["guid"],
211    },
212  },
213};
214
215/**
216 * Validating and processing API querying data
217 */
218
219const validFields = [
220  "fieldname",
221  "value",
222  "timesUsed",
223  "firstUsed",
224  "lastUsed",
225  "guid",
226];
227
228const searchFilters = [
229  "firstUsedStart",
230  "firstUsedEnd",
231  "lastUsedStart",
232  "lastUsedEnd",
233];
234
235function validateOpData(aData, aDataType) {
236  let thisValidFields = validFields;
237  // A special case to update the GUID - in this case there can be a 'newGuid'
238  // field and of the normally valid fields, only 'guid' is accepted.
239  if (aDataType == "Update" && "newGuid" in aData) {
240    thisValidFields = ["guid", "newGuid"];
241  }
242  for (let field in aData) {
243    if (field != "op" && !thisValidFields.includes(field)) {
244      throw Components.Exception(
245        aDataType + " query contains an unrecognized field: " + field,
246        Cr.NS_ERROR_ILLEGAL_VALUE
247      );
248    }
249  }
250  return aData;
251}
252
253function validateSearchData(aData, aDataType) {
254  for (let field in aData) {
255    if (
256      field != "op" &&
257      !validFields.includes(field) &&
258      !searchFilters.includes(field)
259    ) {
260      throw Components.Exception(
261        aDataType + " query contains an unrecognized field: " + field,
262        Cr.NS_ERROR_ILLEGAL_VALUE
263      );
264    }
265  }
266}
267
268function makeQueryPredicates(aQueryData, delimiter = " AND ") {
269  return Object.keys(aQueryData)
270    .map(field => {
271      switch (field) {
272        case "firstUsedStart": {
273          return "firstUsed >= :" + field;
274        }
275        case "firstUsedEnd": {
276          return "firstUsed <= :" + field;
277        }
278        case "lastUsedStart": {
279          return "lastUsed >= :" + field;
280        }
281        case "lastUsedEnd": {
282          return "lastUsed <= :" + field;
283        }
284      }
285
286      return field + " = :" + field;
287    })
288    .join(delimiter);
289}
290
291function generateGUID() {
292  // string like: "{f60d9eac-9421-4abc-8491-8e8322b063d4}"
293  let uuid = uuidService.generateUUID().toString();
294  let raw = ""; // A string with the low bytes set to random values
295  let bytes = 0;
296  for (let i = 1; bytes < 12; i += 2) {
297    // Skip dashes
298    if (uuid[i] == "-") {
299      i++;
300    }
301    let hexVal = parseInt(uuid[i] + uuid[i + 1], 16);
302    raw += String.fromCharCode(hexVal);
303    bytes++;
304  }
305  return btoa(raw);
306}
307
308var Migrators = {
309  /*
310   * Updates the DB schema to v3 (bug 506402).
311   * Adds deleted form history table.
312   */
313  async dbAsyncMigrateToVersion4(conn) {
314    const TABLE_NAME = "moz_deleted_formhistory";
315    let tableExists = await conn.tableExists(TABLE_NAME);
316    if (!tableExists) {
317      let table = dbSchema.tables[TABLE_NAME];
318      let tSQL = Object.keys(table)
319        .map(col => [col, table[col]].join(" "))
320        .join(", ");
321      await conn.execute(`CREATE TABLE ${TABLE_NAME} (${tSQL})`);
322    }
323  },
324};
325
326/**
327 * @typedef {Object} InsertQueryData
328 * @property {Object} updatedChange
329 *           A change requested by FormHistory.
330 * @property {String} query
331 *           The insert query string.
332 */
333
334/**
335 * Prepares a query and some default parameters when inserting an entry
336 * to the database.
337 *
338 * @param {Object} change
339 *        The change requested by FormHistory.
340 * @param {number} now
341 *        The current timestamp in microseconds.
342 * @returns {InsertQueryData}
343 *          The query information needed to pass along to the database.
344 */
345function prepareInsertQuery(change, now) {
346  let updatedChange = Object.assign({}, change);
347  let query =
348    "INSERT INTO moz_formhistory " +
349    "(fieldname, value, timesUsed, firstUsed, lastUsed, guid) " +
350    "VALUES (:fieldname, :value, :timesUsed, :firstUsed, :lastUsed, :guid)";
351  updatedChange.timesUsed = updatedChange.timesUsed || 1;
352  updatedChange.firstUsed = updatedChange.firstUsed || now;
353  updatedChange.lastUsed = updatedChange.lastUsed || now;
354
355  return {
356    updatedChange,
357    query,
358  };
359}
360
361// There is a fieldname / value uniqueness constraint that's at this time
362// only enforced at this level. This Map maps fieldnames => values that
363// are in the process of being inserted into the database so that we know
364// not to try to insert the same ones on top. Attempts to do so will be
365// ignored.
366var InProgressInserts = {
367  _inProgress: new Map(),
368
369  add(fieldname, value) {
370    let fieldnameSet = this._inProgress.get(fieldname);
371    if (!fieldnameSet) {
372      this._inProgress.set(fieldname, new Set([value]));
373      return true;
374    }
375
376    if (!fieldnameSet.has(value)) {
377      fieldnameSet.add(value);
378      return true;
379    }
380
381    return false;
382  },
383
384  clear(fieldnamesAndValues) {
385    for (let [fieldname, value] of fieldnamesAndValues) {
386      let fieldnameSet = this._inProgress.get(fieldname);
387      if (
388        fieldnameSet &&
389        fieldnameSet.delete(value) &&
390        fieldnameSet.size == 0
391      ) {
392        this._inProgress.delete(fieldname);
393      }
394    }
395  },
396};
397
398/**
399 * Constructs and executes database statements from a pre-processed list of
400 * inputted changes.
401 *
402 * @param {Array.<Object>} aChanges changes to form history
403 * @param {Object} aPreparedHandlers
404 */
405// XXX This should be split up and the complexity reduced.
406// eslint-disable-next-line complexity
407async function updateFormHistoryWrite(aChanges, aPreparedHandlers) {
408  log("updateFormHistoryWrite  " + aChanges.length);
409
410  // pass 'now' down so that every entry in the batch has the same timestamp
411  let now = Date.now() * 1000;
412  let queries = [];
413  let notifications = [];
414  let adds = [];
415  let conn = await FormHistory.db;
416
417  for (let change of aChanges) {
418    let operation = change.op;
419    delete change.op;
420    switch (operation) {
421      case "remove": {
422        log("Remove from form history  " + change);
423        let queryTerms = makeQueryPredicates(change);
424
425        // Fetch the GUIDs we are going to delete.
426        try {
427          let query = "SELECT guid FROM moz_formhistory";
428          if (queryTerms) {
429            query += " WHERE " + queryTerms;
430          }
431
432          await conn.executeCached(query, change, row => {
433            notifications.push([
434              "formhistory-remove",
435              row.getResultByName("guid"),
436            ]);
437          });
438        } catch (e) {
439          log("Error getting guids from moz_formhistory: " + e);
440        }
441
442        if (supportsDeletedTable) {
443          log("Moving to deleted table " + change);
444          let query = "INSERT INTO moz_deleted_formhistory (guid, timeDeleted)";
445
446          // TODO: Add these items to the deleted items table once we've sorted
447          //       out the issues from bug 756701
448          if (change.guid || queryTerms) {
449            query += change.guid
450              ? " VALUES (:guid, :timeDeleted)"
451              : " SELECT guid, :timeDeleted FROM moz_formhistory WHERE " +
452                queryTerms;
453            change.timeDeleted = now;
454            queries.push({ query, params: Object.assign({}, change) });
455          }
456
457          if ("timeDeleted" in change) {
458            delete change.timeDeleted;
459          }
460        }
461
462        let query = "DELETE FROM moz_formhistory";
463        if (queryTerms) {
464          log("removeEntries");
465          query += " WHERE " + queryTerms;
466        } else {
467          log("removeAllEntries");
468          // Not specifying any fields means we should remove all entries. We
469          // won't need to modify the query in this case.
470        }
471
472        queries.push({ query, params: change });
473        break;
474      }
475      case "update": {
476        log("Update form history " + change);
477        let guid = change.guid;
478        delete change.guid;
479        // a special case for updating the GUID - the new value can be
480        // specified in newGuid.
481        if (change.newGuid) {
482          change.guid = change.newGuid;
483          delete change.newGuid;
484        }
485
486        let query = "UPDATE moz_formhistory SET ";
487        let queryTerms = makeQueryPredicates(change, ", ");
488        if (!queryTerms) {
489          throw Components.Exception(
490            "Update query must define fields to modify.",
491            Cr.NS_ERROR_ILLEGAL_VALUE
492          );
493        }
494        query += queryTerms + " WHERE guid = :existing_guid";
495        change.existing_guid = guid;
496        queries.push({ query, params: change });
497        notifications.push(["formhistory-update", guid]);
498        break;
499      }
500      case "bump": {
501        log("Bump form history " + change);
502        if (change.guid) {
503          let query =
504            "UPDATE moz_formhistory " +
505            "SET timesUsed = timesUsed + 1, lastUsed = :lastUsed WHERE guid = :guid";
506          let queryParams = {
507            lastUsed: now,
508            guid: change.guid,
509          };
510
511          queries.push({ query, params: queryParams });
512          notifications.push(["formhistory-update", change.guid]);
513        } else {
514          if (!InProgressInserts.add(change.fieldname, change.value)) {
515            // This updateFormHistoryWrite call, or a previous one, is already
516            // going to add this fieldname / value pair, so we can ignore this.
517            continue;
518          }
519          adds.push([change.fieldname, change.value]);
520          change.guid = generateGUID();
521          let { query, updatedChange } = prepareInsertQuery(change, now);
522          queries.push({ query, params: updatedChange });
523          notifications.push(["formhistory-add", updatedChange.guid]);
524        }
525        break;
526      }
527      case "add": {
528        if (!InProgressInserts.add(change.fieldname, change.value)) {
529          // This updateFormHistoryWrite call, or a previous one, is already
530          // going to add this fieldname / value pair, so we can ignore this.
531          continue;
532        }
533        adds.push([change.fieldname, change.value]);
534
535        log("Add to form history " + change);
536        if (!change.guid) {
537          change.guid = generateGUID();
538        }
539
540        let { query, updatedChange } = prepareInsertQuery(change, now);
541        queries.push({ query, params: updatedChange });
542        notifications.push(["formhistory-add", updatedChange.guid]);
543        break;
544      }
545      default: {
546        // We should've already guaranteed that change.op is one of the above
547        throw Components.Exception(
548          "Invalid operation " + operation,
549          Cr.NS_ERROR_ILLEGAL_VALUE
550        );
551      }
552    }
553  }
554
555  try {
556    await runUpdateQueries(conn, queries);
557    for (let [notification, param] of notifications) {
558      // We're either sending a GUID or nothing at all.
559      sendNotification(notification, param);
560    }
561
562    aPreparedHandlers.handleCompletion(0);
563  } catch (e) {
564    aPreparedHandlers.handleError(e);
565    aPreparedHandlers.handleCompletion(1);
566  } finally {
567    InProgressInserts.clear(adds);
568  }
569}
570
571/**
572 * Runs queries for an update operation to the database. This
573 * is separated out from updateFormHistoryWrite to avoid shutdown
574 * leaks where the handlers passed to updateFormHistoryWrite would
575 * leak from the closure around the executeTransaction function.
576 *
577 * @param {SqliteConnection} conn the database connection
578 * @param {Object} queries query string and param pairs generated
579 *                 by updateFormHistoryWrite
580 */
581async function runUpdateQueries(conn, queries) {
582  await conn.executeTransaction(async () => {
583    for (let { query, params } of queries) {
584      await conn.executeCached(query, params);
585    }
586  });
587}
588
589/**
590 * Functions that expire entries in form history and shrinks database
591 * afterwards as necessary initiated by expireOldEntries.
592 */
593
594/**
595 * Removes entries from database.
596 *
597 * @param {number} aExpireTime expiration timestamp
598 * @param {number} aBeginningCount numer of entries at first
599 */
600function expireOldEntriesDeletion(aExpireTime, aBeginningCount) {
601  log("expireOldEntriesDeletion(" + aExpireTime + "," + aBeginningCount + ")");
602
603  FormHistory.update(
604    [
605      {
606        op: "remove",
607        lastUsedEnd: aExpireTime,
608      },
609    ],
610    {
611      handleCompletion() {
612        expireOldEntriesVacuum(aExpireTime, aBeginningCount);
613      },
614      handleError(aError) {
615        log("expireOldEntriesDeletionFailure");
616      },
617    }
618  );
619}
620
621/**
622 * Counts number of entries removed and shrinks database as necessary.
623 *
624 * @param {number} aExpireTime expiration timestamp
625 * @param {number} aBeginningCount number of entries at first
626 */
627function expireOldEntriesVacuum(aExpireTime, aBeginningCount) {
628  FormHistory.count(
629    {},
630    {
631      handleResult(aEndingCount) {
632        if (aBeginningCount - aEndingCount > 500) {
633          log("expireOldEntriesVacuum");
634
635          FormHistory.db.then(async conn => {
636            try {
637              await conn.executeCached("VACUUM");
638            } catch (e) {
639              log("expireVacuumError");
640            }
641          });
642        }
643
644        sendNotification("formhistory-expireoldentries", aExpireTime);
645      },
646      handleError(aError) {
647        log("expireEndCountFailure");
648      },
649    }
650  );
651}
652
653/**
654 * Database creation and access. Used by FormHistory and some of the
655 * utility functions, but is not exposed to the outside world.
656 * @class
657 */
658var DB = {
659  // Once we establish a database connection, we have to hold a reference
660  // to it so that it won't get GC'd.
661  _instance: null,
662  // MAX_ATTEMPTS is how many times we'll try to establish a connection
663  // or migrate a database before giving up.
664  MAX_ATTEMPTS: 2,
665
666  /** String representing where the FormHistory database is on the filesystem */
667  get path() {
668    return OS.Path.join(OS.Constants.Path.profileDir, DB_FILENAME);
669  },
670
671  /**
672   * Sets up and returns a connection to the FormHistory database. The
673   * connection also registers itself with AsyncShutdown so that the
674   * connection is closed on when the profile-before-change observer
675   * notification is fired.
676   *
677   * @returns {Promise}
678   * @resolves An Sqlite.jsm connection to the database.
679   * @rejects  If connecting to the database, or migrating the database
680   *           failed after MAX_ATTEMPTS attempts (where each attempt
681   *           backs up and deletes the old database), this will reject
682   *           with the Sqlite.jsm error.
683   */
684  get conn() {
685    delete this.conn;
686    let conn = (async () => {
687      try {
688        this._instance = await this._establishConn();
689      } catch (e) {
690        log("Failed to establish database connection: " + e);
691        throw e;
692      }
693
694      return this._instance;
695    })();
696
697    return (this.conn = conn);
698  },
699
700  // Private functions
701
702  /**
703   * Tries to connect to the Sqlite database at this.path, and then
704   * migrates the database as necessary. If any of the steps to do this
705   * fail, this function should re-enter itself with an incremented
706   * attemptNum so that another attempt can be made after backing up
707   * and deleting the old database.
708   *
709   * @async
710   * @param {number} attemptNum
711   *        The optional number of the attempt that is being made to connect
712   *        to the database. Defaults to 0.
713   * @returns {Promise}
714   * @resolves An Sqlite.jsm connection to the database.
715   * @rejects  After MAX_ATTEMPTS, this will reject with the Sqlite.jsm
716   *           error.
717   */
718  async _establishConn(attemptNum = 0) {
719    log(`Establishing database connection - attempt # ${attemptNum}`);
720    let conn;
721    try {
722      conn = await Sqlite.openConnection({ path: this.path });
723      Sqlite.shutdown.addBlocker("Closing FormHistory database.", () =>
724        conn.close()
725      );
726    } catch (e) {
727      // Bug 1423729 - We should check the reason for the connection failure,
728      // in case this is due to the disk being full or the database file being
729      // inaccessible due to third-party software (like anti-virus software).
730      // In that case, we should probably fail right away.
731      if (attemptNum < this.MAX_ATTEMPTS) {
732        log("Establishing connection failed.");
733        await this._failover(conn);
734        return this._establishConn(++attemptNum);
735      }
736
737      if (conn) {
738        await conn.close();
739      }
740      log("Establishing connection failed too many times. Giving up.");
741      throw e;
742    }
743
744    try {
745      let dbVersion = parseInt(await conn.getSchemaVersion(), 10);
746
747      // Case 1: Database is up to date and we're ready to go.
748      if (dbVersion == DB_SCHEMA_VERSION) {
749        return conn;
750      }
751
752      // Case 2: Downgrade
753      if (dbVersion > DB_SCHEMA_VERSION) {
754        log("Downgrading to version " + DB_SCHEMA_VERSION);
755        // User's DB is newer. Sanity check that our expected columns are
756        // present, and if so mark the lower version and merrily continue
757        // on. If the columns are borked, something is wrong so blow away
758        // the DB and start from scratch. [Future incompatible upgrades
759        // should switch to a different table or file.]
760        if (!(await this._expectedColumnsPresent(conn))) {
761          throw Components.Exception(
762            "DB is missing expected columns",
763            Cr.NS_ERROR_FILE_CORRUPTED
764          );
765        }
766
767        // Change the stored version to the current version. If the user
768        // runs the newer code again, it will see the lower version number
769        // and re-upgrade (to fixup any entries the old code added).
770        await conn.setSchemaVersion(DB_SCHEMA_VERSION);
771        return conn;
772      }
773
774      // Case 3: Very old database that cannot be migrated.
775      //
776      // When FormHistory is released, we will no longer support the various
777      // schema versions prior to this release that nsIFormHistory2 once did.
778      // We'll throw an NS_ERROR_FILE_CORRUPTED, which should cause us to wipe
779      // out this DB and create a new one (unless this is our MAX_ATTEMPTS
780      // attempt).
781      if (dbVersion > 0 && dbVersion < 3) {
782        throw Components.Exception(
783          "DB version is unsupported.",
784          Cr.NS_ERROR_FILE_CORRUPTED
785        );
786      }
787
788      if (dbVersion == 0) {
789        // Case 4: New database
790        await conn.executeTransaction(async () => {
791          log("Creating DB -- tables");
792          for (let name in dbSchema.tables) {
793            let table = dbSchema.tables[name];
794            let tSQL = Object.keys(table)
795              .map(col => [col, table[col]].join(" "))
796              .join(", ");
797            log("Creating table " + name + " with " + tSQL);
798            await conn.execute(`CREATE TABLE ${name} (${tSQL})`);
799          }
800
801          log("Creating DB -- indices");
802          for (let name in dbSchema.indices) {
803            let index = dbSchema.indices[name];
804            let statement =
805              "CREATE INDEX IF NOT EXISTS " +
806              name +
807              " ON " +
808              index.table +
809              "(" +
810              index.columns.join(", ") +
811              ")";
812            await conn.execute(statement);
813          }
814        });
815      } else {
816        // Case 5: Old database requiring a migration
817        await conn.executeTransaction(async () => {
818          for (let v = dbVersion + 1; v <= DB_SCHEMA_VERSION; v++) {
819            log("Upgrading to version " + v + "...");
820            await Migrators["dbAsyncMigrateToVersion" + v](conn);
821          }
822        });
823      }
824
825      await conn.setSchemaVersion(DB_SCHEMA_VERSION);
826
827      return conn;
828    } catch (e) {
829      if (e.result != Cr.NS_ERROR_FILE_CORRUPTED) {
830        throw e;
831      }
832
833      if (attemptNum < this.MAX_ATTEMPTS) {
834        log("Setting up database failed.");
835        await this._failover(conn);
836        return this._establishConn(++attemptNum);
837      }
838
839      if (conn) {
840        await conn.close();
841      }
842
843      log("Setting up database failed too many times. Giving up.");
844
845      throw e;
846    }
847  },
848
849  /**
850   * Closes a connection to the database, then backs up the database before
851   * deleting it.
852   *
853   * @async
854   * @param {SqliteConnection | null} conn
855   *        The connection to the database that we failed to establish or
856   *        migrate.
857   * @throws If any file operations fail.
858   */
859  async _failover(conn) {
860    log("Cleaning up DB file - close & remove & backup.");
861    if (conn) {
862      await conn.close();
863    }
864    let backupFile = this.path + ".corrupt";
865    let { file, path: uniquePath } = await OS.File.openUnique(backupFile, {
866      humanReadable: true,
867    });
868    await file.close();
869    await OS.File.copy(this.path, uniquePath);
870    await OS.File.remove(this.path);
871    log("Completed DB cleanup.");
872  },
873
874  /**
875   * Tests that a database connection contains the tables that we expect.
876   *
877   * @async
878   * @param {SqliteConnection | null} conn
879   *        The connection to the database that we're testing.
880   * @returns {Promise}
881   * @resolves true if all expected columns are present.
882   */
883  async _expectedColumnsPresent(conn) {
884    for (let name in dbSchema.tables) {
885      let table = dbSchema.tables[name];
886      let query = "SELECT " + Object.keys(table).join(", ") + " FROM " + name;
887      try {
888        await conn.execute(query, null, (row, cancel) => {
889          // One row is enough to let us know this worked.
890          cancel();
891        });
892      } catch (e) {
893        return false;
894      }
895    }
896
897    log("Verified that expected columns are present in DB.");
898    return true;
899  },
900};
901
902this.FormHistory = {
903  get db() {
904    return DB.conn;
905  },
906
907  get enabled() {
908    return Prefs.enabled;
909  },
910
911  _prepareHandlers(handlers) {
912    let defaultHandlers = {
913      handleResult: NOOP,
914      handleError: NOOP,
915      handleCompletion: NOOP,
916    };
917
918    if (!handlers) {
919      return defaultHandlers;
920    }
921
922    if (handlers.handleResult) {
923      defaultHandlers.handleResult = handlers.handleResult;
924    }
925    if (handlers.handleError) {
926      defaultHandlers.handleError = handlers.handleError;
927    }
928    if (handlers.handleCompletion) {
929      defaultHandlers.handleCompletion = handlers.handleCompletion;
930    }
931
932    return defaultHandlers;
933  },
934
935  search(aSelectTerms, aSearchData, aRowFuncOrHandlers) {
936    // if no terms selected, select everything
937    if (!aSelectTerms) {
938      aSelectTerms = validFields;
939    }
940    validateSearchData(aSearchData, "Search");
941
942    let query = "SELECT " + aSelectTerms.join(", ") + " FROM moz_formhistory";
943    let queryTerms = makeQueryPredicates(aSearchData);
944    if (queryTerms) {
945      query += " WHERE " + queryTerms;
946    }
947
948    let handlers;
949
950    if (typeof aRowFuncOrHandlers == "function") {
951      handlers = this._prepareHandlers();
952      handlers.handleResult = aRowFuncOrHandlers;
953    } else if (typeof aRowFuncOrHandlers == "object") {
954      handlers = this._prepareHandlers(aRowFuncOrHandlers);
955    }
956
957    let allResults = [];
958
959    return new Promise((resolve, reject) => {
960      this.db.then(async conn => {
961        try {
962          await conn.executeCached(query, aSearchData, row => {
963            let result = {};
964            for (let field of aSelectTerms) {
965              result[field] = row.getResultByName(field);
966            }
967
968            if (handlers) {
969              handlers.handleResult(result);
970            } else {
971              allResults.push(result);
972            }
973          });
974          if (handlers) {
975            handlers.handleCompletion(0);
976          }
977          resolve(allResults);
978        } catch (e) {
979          if (handlers) {
980            handlers.handleError(e);
981            handlers.handleCompletion(1);
982          }
983          reject(e);
984        }
985      });
986    });
987  },
988
989  count(aSearchData, aHandlers) {
990    validateSearchData(aSearchData, "Count");
991
992    let query = "SELECT COUNT(*) AS numEntries FROM moz_formhistory";
993    let queryTerms = makeQueryPredicates(aSearchData);
994    if (queryTerms) {
995      query += " WHERE " + queryTerms;
996    }
997
998    let handlers = this._prepareHandlers(aHandlers);
999
1000    return new Promise((resolve, reject) => {
1001      this.db.then(async conn => {
1002        try {
1003          let rows = await conn.executeCached(query, aSearchData);
1004          let count = rows[0].getResultByName("numEntries");
1005          handlers.handleResult(count);
1006          handlers.handleCompletion(0);
1007          resolve(count);
1008        } catch (e) {
1009          handlers.handleError(e);
1010          handlers.handleCompletion(1);
1011          reject(e);
1012        }
1013      });
1014    });
1015  },
1016
1017  update(aChanges, aHandlers) {
1018    // Used to keep track of how many searches have been started. When that number
1019    // are finished, updateFormHistoryWrite can be called.
1020    let numSearches = 0;
1021    let completedSearches = 0;
1022    let searchFailed = false;
1023
1024    function validIdentifier(change) {
1025      // The identifier is only valid if one of either the guid
1026      // or the (fieldname/value) are set (so an X-OR)
1027      return Boolean(change.guid) != Boolean(change.fieldname && change.value);
1028    }
1029
1030    if (!("length" in aChanges)) {
1031      aChanges = [aChanges];
1032    }
1033
1034    let handlers = this._prepareHandlers(aHandlers);
1035
1036    let isRemoveOperation = aChanges.every(
1037      change => change && change.op && change.op == "remove"
1038    );
1039    if (!Prefs.enabled && !isRemoveOperation) {
1040      handlers.handleError({
1041        message: "Form history is disabled, only remove operations are allowed",
1042        result: Ci.mozIStorageError.MISUSE,
1043      });
1044      handlers.handleCompletion(1);
1045      return;
1046    }
1047
1048    for (let change of aChanges) {
1049      switch (change.op) {
1050        case "remove":
1051          validateSearchData(change, "Remove");
1052          continue;
1053        case "update":
1054          if (validIdentifier(change)) {
1055            validateOpData(change, "Update");
1056            if (change.guid) {
1057              continue;
1058            }
1059          } else {
1060            throw Components.Exception(
1061              "update op='update' does not correctly reference a entry.",
1062              Cr.NS_ERROR_ILLEGAL_VALUE
1063            );
1064          }
1065          break;
1066        case "bump":
1067          if (validIdentifier(change)) {
1068            validateOpData(change, "Bump");
1069            if (change.guid) {
1070              continue;
1071            }
1072          } else {
1073            throw Components.Exception(
1074              "update op='bump' does not correctly reference a entry.",
1075              Cr.NS_ERROR_ILLEGAL_VALUE
1076            );
1077          }
1078          break;
1079        case "add":
1080          if (change.fieldname && change.value) {
1081            validateOpData(change, "Add");
1082          } else {
1083            throw Components.Exception(
1084              "update op='add' must have a fieldname and a value.",
1085              Cr.NS_ERROR_ILLEGAL_VALUE
1086            );
1087          }
1088          break;
1089        default:
1090          throw Components.Exception(
1091            "update does not recognize op='" + change.op + "'",
1092            Cr.NS_ERROR_ILLEGAL_VALUE
1093          );
1094      }
1095
1096      numSearches++;
1097      let changeToUpdate = change;
1098      FormHistory.search(
1099        ["guid"],
1100        {
1101          fieldname: change.fieldname,
1102          value: change.value,
1103        },
1104        {
1105          foundResult: false,
1106          handleResult(aResult) {
1107            if (this.foundResult) {
1108              log(
1109                "Database contains multiple entries with the same fieldname/value pair."
1110              );
1111              handlers.handleError({
1112                message:
1113                  "Database contains multiple entries with the same fieldname/value pair.",
1114                result: 19, // Constraint violation
1115              });
1116
1117              searchFailed = true;
1118              return;
1119            }
1120
1121            this.foundResult = true;
1122            changeToUpdate.guid = aResult.guid;
1123          },
1124
1125          handleError(aError) {
1126            handlers.handleError(aError);
1127          },
1128
1129          handleCompletion(aReason) {
1130            completedSearches++;
1131            if (completedSearches == numSearches) {
1132              if (!aReason && !searchFailed) {
1133                updateFormHistoryWrite(aChanges, handlers);
1134              } else {
1135                handlers.handleCompletion(1);
1136              }
1137            }
1138          },
1139        }
1140      );
1141    }
1142
1143    if (numSearches == 0) {
1144      // We don't have to wait for any statements to return.
1145      updateFormHistoryWrite(aChanges, handlers);
1146    }
1147  },
1148
1149  getAutoCompleteResults(searchString, params, aHandlers) {
1150    // only do substring matching when the search string contains more than one character
1151    let searchTokens;
1152    let where = "";
1153    let boundaryCalc = "";
1154
1155    if (searchString.length >= 1) {
1156      params.valuePrefix = searchString + "%";
1157    }
1158
1159    if (searchString.length > 1) {
1160      searchTokens = searchString.split(/\s+/);
1161
1162      // build up the word boundary and prefix match bonus calculation
1163      boundaryCalc =
1164        "MAX(1, :prefixWeight * (value LIKE :valuePrefix ESCAPE '/') + (";
1165      // for each word, calculate word boundary weights for the SELECT clause and
1166      // add word to the WHERE clause of the query
1167      let tokenCalc = [];
1168      let searchTokenCount = Math.min(searchTokens.length, MAX_SEARCH_TOKENS);
1169      for (let i = 0; i < searchTokenCount; i++) {
1170        let escapedToken = searchTokens[i];
1171        params["tokenBegin" + i] = escapedToken + "%";
1172        params["tokenBoundary" + i] = "% " + escapedToken + "%";
1173        params["tokenContains" + i] = "%" + escapedToken + "%";
1174
1175        tokenCalc.push(
1176          "(value LIKE :tokenBegin" +
1177            i +
1178            " ESCAPE '/') + " +
1179            "(value LIKE :tokenBoundary" +
1180            i +
1181            " ESCAPE '/')"
1182        );
1183        where += "AND (value LIKE :tokenContains" + i + " ESCAPE '/') ";
1184      }
1185      // add more weight if we have a traditional prefix match and
1186      // multiply boundary bonuses by boundary weight
1187      boundaryCalc += tokenCalc.join(" + ") + ") * :boundaryWeight)";
1188    } else if (searchString.length == 1) {
1189      where = "AND (value LIKE :valuePrefix ESCAPE '/') ";
1190      boundaryCalc = "1";
1191      delete params.prefixWeight;
1192      delete params.boundaryWeight;
1193    } else {
1194      where = "";
1195      boundaryCalc = "1";
1196      delete params.prefixWeight;
1197      delete params.boundaryWeight;
1198    }
1199
1200    params.now = Date.now() * 1000; // convert from ms to microseconds
1201
1202    let handlers = this._prepareHandlers(aHandlers);
1203
1204    /* Three factors in the frecency calculation for an entry (in order of use in calculation):
1205     * 1) average number of times used - items used more are ranked higher
1206     * 2) how recently it was last used - items used recently are ranked higher
1207     * 3) additional weight for aged entries surviving expiry - these entries are relevant
1208     *    since they have been used multiple times over a large time span so rank them higher
1209     * The score is then divided by the bucket size and we round the result so that entries
1210     * with a very similar frecency are bucketed together with an alphabetical sort. This is
1211     * to reduce the amount of moving around by entries while typing.
1212     */
1213
1214    let query =
1215      "/* do not warn (bug 496471): can't use an index */ " +
1216      "SELECT value, guid, " +
1217      "ROUND( " +
1218      "timesUsed / MAX(1.0, (lastUsed - firstUsed) / :timeGroupingSize) * " +
1219      "MAX(1.0, :maxTimeGroupings - (:now - lastUsed) / :timeGroupingSize) * " +
1220      "MAX(1.0, :agedWeight * (firstUsed < :expiryDate)) / " +
1221      ":bucketSize " +
1222      ", 3) AS frecency, " +
1223      boundaryCalc +
1224      " AS boundaryBonuses " +
1225      "FROM moz_formhistory " +
1226      "WHERE fieldname=:fieldname " +
1227      where +
1228      "ORDER BY ROUND(frecency * boundaryBonuses) DESC, UPPER(value) ASC";
1229
1230    let cancelled = false;
1231
1232    let cancellableQuery = {
1233      cancel() {
1234        cancelled = true;
1235      },
1236    };
1237
1238    this.db.then(async conn => {
1239      try {
1240        await conn.executeCached(query, params, (row, cancel) => {
1241          if (cancelled) {
1242            cancel();
1243            return;
1244          }
1245
1246          let value = row.getResultByName("value");
1247          let guid = row.getResultByName("guid");
1248          let frecency = row.getResultByName("frecency");
1249          let entry = {
1250            text: value,
1251            guid,
1252            textLowerCase: value.toLowerCase(),
1253            frecency,
1254            totalScore: Math.round(
1255              frecency * row.getResultByName("boundaryBonuses")
1256            ),
1257          };
1258          handlers.handleResult(entry);
1259        });
1260        handlers.handleCompletion(0);
1261      } catch (e) {
1262        handlers.handleError(e);
1263        handlers.handleCompletion(1);
1264      }
1265    });
1266
1267    return cancellableQuery;
1268  },
1269
1270  // This is used only so that the test can verify deleted table support.
1271  get _supportsDeletedTable() {
1272    return supportsDeletedTable;
1273  },
1274  set _supportsDeletedTable(val) {
1275    supportsDeletedTable = val;
1276  },
1277
1278  // The remaining methods are called by FormHistoryStartup.js
1279  updatePrefs() {
1280    Prefs.initialized = false;
1281  },
1282
1283  expireOldEntries() {
1284    log("expireOldEntries");
1285
1286    // Determine how many days of history we're supposed to keep.
1287    // Calculate expireTime in microseconds
1288    let expireTime = (Date.now() - Prefs.expireDays * DAY_IN_MS) * 1000;
1289
1290    sendNotification("formhistory-beforeexpireoldentries", expireTime);
1291
1292    FormHistory.count(
1293      {},
1294      {
1295        handleResult(aBeginningCount) {
1296          expireOldEntriesDeletion(expireTime, aBeginningCount);
1297        },
1298        handleError(aError) {
1299          log("expireStartCountFailure");
1300        },
1301      }
1302    );
1303  },
1304};
1305
1306// Prevent add-ons from redefining this API
1307Object.freeze(FormHistory);
1308