tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

history.sys.mjs (20998B)


      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 
      5 const HISTORY_TTL = 5184000; // 60 days in milliseconds
      6 const THIRTY_DAYS_IN_MS = 2592000000; // 30 days in milliseconds
      7 // Sync may bring new fields from other clients, not yet understood by our engine.
      8 // Unknown fields outside these fields are aggregated into 'unknownFields' and
      9 // safely synced to prevent data loss.
     10 const VALID_HISTORY_FIELDS = ["id", "title", "histUri", "visits"];
     11 const VALID_VISIT_FIELDS = ["date", "type", "transition"];
     12 
     13 import { Async } from "resource://services-common/async.sys.mjs";
     14 import { CommonUtils } from "resource://services-common/utils.sys.mjs";
     15 
     16 import {
     17  MAX_HISTORY_DOWNLOAD,
     18  MAX_HISTORY_UPLOAD,
     19  SCORE_INCREMENT_SMALL,
     20  SCORE_INCREMENT_XLARGE,
     21 } from "resource://services-sync/constants.sys.mjs";
     22 
     23 import {
     24  Store,
     25  SyncEngine,
     26  LegacyTracker,
     27 } from "resource://services-sync/engines.sys.mjs";
     28 import { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
     29 import { Utils } from "resource://services-sync/util.sys.mjs";
     30 
     31 const lazy = {};
     32 
     33 ChromeUtils.defineESModuleGetters(lazy, {
     34  PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.sys.mjs",
     35  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
     36 });
     37 
     38 export function HistoryRec(collection, id) {
     39  CryptoWrapper.call(this, collection, id);
     40 }
     41 
     42 HistoryRec.prototype = {
     43  _logName: "Sync.Record.History",
     44  ttl: HISTORY_TTL,
     45 };
     46 Object.setPrototypeOf(HistoryRec.prototype, CryptoWrapper.prototype);
     47 
     48 Utils.deferGetSet(HistoryRec, "cleartext", ["histUri", "title", "visits"]);
     49 
     50 export function HistoryEngine(service) {
     51  SyncEngine.call(this, "History", service);
     52 }
     53 
     54 HistoryEngine.prototype = {
     55  _recordObj: HistoryRec,
     56  _storeObj: HistoryStore,
     57  _trackerObj: HistoryTracker,
     58  downloadLimit: MAX_HISTORY_DOWNLOAD,
     59 
     60  syncPriority: 7,
     61 
     62  async getSyncID() {
     63    return lazy.PlacesSyncUtils.history.getSyncId();
     64  },
     65 
     66  async ensureCurrentSyncID(newSyncID) {
     67    this._log.debug(
     68      "Checking if server sync ID ${newSyncID} matches existing",
     69      { newSyncID }
     70    );
     71    await lazy.PlacesSyncUtils.history.ensureCurrentSyncId(newSyncID);
     72    return newSyncID;
     73  },
     74 
     75  async resetSyncID() {
     76    // First, delete the collection on the server. It's fine if we're
     77    // interrupted here: on the next sync, we'll detect that our old sync ID is
     78    // now stale, and start over as a first sync.
     79    await this._deleteServerCollection();
     80    // Then, reset our local sync ID.
     81    return this.resetLocalSyncID();
     82  },
     83 
     84  async resetLocalSyncID() {
     85    let newSyncID = await lazy.PlacesSyncUtils.history.resetSyncId();
     86    this._log.debug("Assigned new sync ID ${newSyncID}", { newSyncID });
     87    return newSyncID;
     88  },
     89 
     90  async getLastSync() {
     91    let lastSync = await lazy.PlacesSyncUtils.history.getLastSync();
     92    return lastSync;
     93  },
     94 
     95  async setLastSync(lastSync) {
     96    await lazy.PlacesSyncUtils.history.setLastSync(lastSync);
     97  },
     98 
     99  shouldSyncURL(url) {
    100    return !url.startsWith("file:");
    101  },
    102 
    103  async pullNewChanges() {
    104    const changedIDs = await this._tracker.getChangedIDs();
    105    let modifiedGUIDs = Object.keys(changedIDs);
    106    if (!modifiedGUIDs.length) {
    107      return {};
    108    }
    109 
    110    let guidsToRemove =
    111      await lazy.PlacesSyncUtils.history.determineNonSyncableGuids(
    112        modifiedGUIDs
    113      );
    114    await this._tracker.removeChangedID(...guidsToRemove);
    115    return changedIDs;
    116  },
    117 
    118  async _resetClient() {
    119    await super._resetClient();
    120    await lazy.PlacesSyncUtils.history.reset();
    121  },
    122 };
    123 Object.setPrototypeOf(HistoryEngine.prototype, SyncEngine.prototype);
    124 
    125 function HistoryStore(name, engine) {
    126  Store.call(this, name, engine);
    127 }
    128 
    129 HistoryStore.prototype = {
    130  // We try and only update this many visits at one time.
    131  MAX_VISITS_PER_INSERT: 500,
    132 
    133  // Some helper functions to handle GUIDs
    134  async setGUID(uri, guid) {
    135    if (!guid) {
    136      guid = Utils.makeGUID();
    137    }
    138 
    139    try {
    140      await lazy.PlacesSyncUtils.history.changeGuid(uri, guid);
    141    } catch (e) {
    142      this._log.error("Error setting GUID ${guid} for URI ${uri}", guid, uri);
    143    }
    144 
    145    return guid;
    146  },
    147 
    148  async GUIDForUri(uri, create) {
    149    // Use the existing GUID if it exists
    150    let guid;
    151    try {
    152      guid = await lazy.PlacesSyncUtils.history.fetchGuidForURL(uri);
    153    } catch (e) {
    154      this._log.error("Error fetching GUID for URL ${uri}", uri);
    155    }
    156 
    157    // If the URI has an existing GUID, return it.
    158    if (guid) {
    159      return guid;
    160    }
    161 
    162    // If the URI doesn't have a GUID and we were indicated to create one.
    163    if (create) {
    164      return this.setGUID(uri);
    165    }
    166 
    167    // If the URI doesn't have a GUID and we didn't create one for it.
    168    return null;
    169  },
    170 
    171  async changeItemID(oldID, newID) {
    172    let info = await lazy.PlacesSyncUtils.history.fetchURLInfoForGuid(oldID);
    173    if (!info) {
    174      throw new Error(`Can't change ID for nonexistent history entry ${oldID}`);
    175    }
    176    this.setGUID(info.url, newID);
    177  },
    178 
    179  async getAllIDs() {
    180    let urls = await lazy.PlacesSyncUtils.history.getAllURLs({
    181      since: new Date(Date.now() - THIRTY_DAYS_IN_MS),
    182      limit: MAX_HISTORY_UPLOAD,
    183    });
    184 
    185    let urlsByGUID = {};
    186    for (let url of urls) {
    187      if (!this.engine.shouldSyncURL(url)) {
    188        continue;
    189      }
    190      let guid = await this.GUIDForUri(url, true);
    191      urlsByGUID[guid] = url;
    192    }
    193    return urlsByGUID;
    194  },
    195 
    196  async applyIncomingBatch(records, countTelemetry) {
    197    // Convert incoming records to mozIPlaceInfo objects which are applied as
    198    // either history additions or removals.
    199    let failed = [];
    200    let toAdd = [];
    201    let toRemove = [];
    202    let pageGuidsWithUnknownFields = new Map();
    203    let visitTimesWithUnknownFields = new Map();
    204    await Async.yieldingForEach(records, async record => {
    205      if (record.deleted) {
    206        toRemove.push(record);
    207      } else {
    208        try {
    209          let pageInfo = await this._recordToPlaceInfo(record);
    210          if (pageInfo) {
    211            toAdd.push(pageInfo);
    212 
    213            // Pull any unknown fields that may have come from other clients
    214            let unknownFields = lazy.PlacesSyncUtils.extractUnknownFields(
    215              record.cleartext,
    216              VALID_HISTORY_FIELDS
    217            );
    218            if (unknownFields) {
    219              pageGuidsWithUnknownFields.set(pageInfo.guid, { unknownFields });
    220            }
    221 
    222            // Visits themselves could also contain unknown fields
    223            for (const visit of pageInfo.visits) {
    224              let unknownVisitFields =
    225                lazy.PlacesSyncUtils.extractUnknownFields(
    226                  visit,
    227                  VALID_VISIT_FIELDS
    228                );
    229              if (unknownVisitFields) {
    230                // Visits don't have an id at the time of sync so we'll need
    231                // to use the time instead until it's inserted in the DB
    232                visitTimesWithUnknownFields.set(visit.date.getTime(), {
    233                  unknownVisitFields,
    234                });
    235              }
    236            }
    237          }
    238        } catch (ex) {
    239          if (Async.isShutdownException(ex)) {
    240            throw ex;
    241          }
    242          this._log.error("Failed to create a place info", ex);
    243          this._log.trace("The record that failed", record);
    244          failed.push(record.id);
    245          countTelemetry.addIncomingFailedReason(ex.message);
    246        }
    247      }
    248    });
    249    if (toAdd.length || toRemove.length) {
    250      if (toRemove.length) {
    251        // PlacesUtils.history.remove takes an array of visits to remove,
    252        // but the error semantics are tricky - a single "bad" entry will cause
    253        // an exception before anything is removed. So we do remove them one at
    254        // a time.
    255        await Async.yieldingForEach(toRemove, async record => {
    256          try {
    257            await this.remove(record);
    258          } catch (ex) {
    259            if (Async.isShutdownException(ex)) {
    260              throw ex;
    261            }
    262            this._log.error("Failed to delete a place info", ex);
    263            this._log.trace("The record that failed", record);
    264            failed.push(record.id);
    265            countTelemetry.addIncomingFailedReason(ex.message);
    266          }
    267        });
    268      }
    269      for (let chunk of this._generateChunks(toAdd)) {
    270        // Per bug 1415560, we ignore any exceptions returned by insertMany
    271        // as they are likely to be spurious. We do supply an onError handler
    272        // and log the exceptions seen there as they are likely to be
    273        // informative, but we still never abort the sync based on them.
    274        let unknownFieldsToInsert = [];
    275        try {
    276          await lazy.PlacesUtils.history.insertMany(
    277            chunk,
    278            result => {
    279              const placeToUpdate = pageGuidsWithUnknownFields.get(result.guid);
    280              // Extract the placeId from this result so we can add the unknownFields
    281              // to the proper table
    282              if (placeToUpdate) {
    283                unknownFieldsToInsert.push({
    284                  placeId: result.placeId,
    285                  unknownFields: placeToUpdate.unknownFields,
    286                });
    287              }
    288              // same for visits
    289              result.visits.forEach(visit => {
    290                let visitToUpdate = visitTimesWithUnknownFields.get(
    291                  visit.date.getTime()
    292                );
    293                if (visitToUpdate) {
    294                  unknownFieldsToInsert.push({
    295                    visitId: visit.visitId,
    296                    unknownFields: visitToUpdate.unknownVisitFields,
    297                  });
    298                }
    299              });
    300            },
    301            failedVisit => {
    302              this._log.info(
    303                "Failed to insert a history record",
    304                failedVisit.guid
    305              );
    306              this._log.trace("The record that failed", failedVisit);
    307              failed.push(failedVisit.guid);
    308            }
    309          );
    310        } catch (ex) {
    311          this._log.info("Failed to insert history records", ex);
    312          countTelemetry.addIncomingFailedReason(ex.message);
    313        }
    314 
    315        // All the top level places or visits that had unknown fields are sent
    316        // to be added to the appropiate tables
    317        await lazy.PlacesSyncUtils.history.updateUnknownFieldsBatch(
    318          unknownFieldsToInsert
    319        );
    320      }
    321    }
    322 
    323    return failed;
    324  },
    325 
    326  /**
    327   * Returns a generator that splits records into sanely sized chunks suitable
    328   * for passing to places to prevent places doing bad things at shutdown.
    329   */
    330  *_generateChunks(records) {
    331    // We chunk based on the number of *visits* inside each record. However,
    332    // we do not split a single record into multiple records, because at some
    333    // time in the future, we intend to ensure these records are ordered by
    334    // lastModified, and advance the engine's timestamp as we process them,
    335    // meaning we can resume exactly where we left off next sync - although
    336    // currently that's not done, so we will retry the entire batch next sync
    337    // if interrupted.
    338    // ie, this means that if a single record has more than MAX_VISITS_PER_INSERT
    339    // visits, we will call insertMany() with exactly 1 record, but with
    340    // more than MAX_VISITS_PER_INSERT visits.
    341    let curIndex = 0;
    342    this._log.debug(`adding ${records.length} records to history`);
    343    while (curIndex < records.length) {
    344      Async.checkAppReady(); // may throw if we are shutting down.
    345      let toAdd = []; // what we are going to insert.
    346      let count = 0; // a counter which tells us when toAdd is full.
    347      do {
    348        let record = records[curIndex];
    349        curIndex += 1;
    350        toAdd.push(record);
    351        count += record.visits.length;
    352      } while (
    353        curIndex < records.length &&
    354        count + records[curIndex].visits.length <= this.MAX_VISITS_PER_INSERT
    355      );
    356      this._log.trace(`adding ${toAdd.length} items in this chunk`);
    357      yield toAdd;
    358    }
    359  },
    360 
    361  /* An internal helper to determine if we can add an entry to places.
    362     Exists primarily so tests can override it.
    363   */
    364  _canAddURI(uri) {
    365    return lazy.PlacesUtils.history.canAddURI(uri);
    366  },
    367 
    368  /**
    369   * Converts a Sync history record to a mozIPlaceInfo.
    370   *
    371   * Throws if an invalid record is encountered (invalid URI, etc.),
    372   * returns a new PageInfo object if the record is to be applied, null
    373   * otherwise (no visits to add, etc.),
    374   */
    375  async _recordToPlaceInfo(record) {
    376    // Sort out invalid URIs and ones Places just simply doesn't want.
    377    record.url = lazy.PlacesUtils.normalizeToURLOrGUID(record.histUri);
    378    record.uri = CommonUtils.makeURI(record.histUri);
    379 
    380    if (!Utils.checkGUID(record.id)) {
    381      this._log.warn("Encountered record with invalid GUID: " + record.id);
    382      return null;
    383    }
    384    record.guid = record.id;
    385 
    386    if (
    387      !this._canAddURI(record.uri) ||
    388      !this.engine.shouldSyncURL(record.uri.spec)
    389    ) {
    390      this._log.trace(
    391        "Ignoring record " +
    392          record.id +
    393          " with URI " +
    394          record.uri.spec +
    395          ": can't add this URI."
    396      );
    397      return null;
    398    }
    399 
    400    // We dupe visits by date and type. So an incoming visit that has
    401    // the same timestamp and type as a local one won't get applied.
    402    // To avoid creating new objects, we rewrite the query result so we
    403    // can simply check for containment below.
    404    let curVisitsAsArray = [];
    405    let curVisits = new Set();
    406    try {
    407      curVisitsAsArray = await lazy.PlacesSyncUtils.history.fetchVisitsForURL(
    408        record.histUri
    409      );
    410    } catch (e) {
    411      this._log.error(
    412        "Error while fetching visits for URL ${record.histUri}",
    413        record.histUri
    414      );
    415    }
    416    let oldestAllowed =
    417      lazy.PlacesSyncUtils.bookmarks.EARLIEST_BOOKMARK_TIMESTAMP;
    418    if (curVisitsAsArray.length == 20) {
    419      let oldestVisit = curVisitsAsArray[curVisitsAsArray.length - 1];
    420      oldestAllowed = lazy.PlacesSyncUtils.history
    421        .clampVisitDate(lazy.PlacesUtils.toDate(oldestVisit.date))
    422        .getTime();
    423    }
    424 
    425    let i, k;
    426    for (i = 0; i < curVisitsAsArray.length; i++) {
    427      // Same logic as used in the loop below to generate visitKey.
    428      let { date, type } = curVisitsAsArray[i];
    429      let dateObj = lazy.PlacesUtils.toDate(date);
    430      let millis = lazy.PlacesSyncUtils.history
    431        .clampVisitDate(dateObj)
    432        .getTime();
    433      curVisits.add(`${millis},${type}`);
    434    }
    435 
    436    // Walk through the visits, make sure we have sound data, and eliminate
    437    // dupes. The latter is done by rewriting the array in-place.
    438    for (i = 0, k = 0; i < record.visits.length; i++) {
    439      let visit = (record.visits[k] = record.visits[i]);
    440 
    441      if (
    442        !visit.date ||
    443        typeof visit.date != "number" ||
    444        !Number.isInteger(visit.date)
    445      ) {
    446        this._log.warn(
    447          "Encountered record with invalid visit date: " + visit.date
    448        );
    449        continue;
    450      }
    451 
    452      if (
    453        !visit.type ||
    454        !Object.values(lazy.PlacesUtils.history.TRANSITIONS).includes(
    455          visit.type
    456        )
    457      ) {
    458        this._log.warn(
    459          "Encountered record with invalid visit type: " +
    460            visit.type +
    461            "; ignoring."
    462        );
    463        continue;
    464      }
    465 
    466      // Dates need to be integers. Future and far past dates are clamped to the
    467      // current date and earliest sensible date, respectively.
    468      let originalVisitDate = lazy.PlacesUtils.toDate(Math.round(visit.date));
    469      visit.date =
    470        lazy.PlacesSyncUtils.history.clampVisitDate(originalVisitDate);
    471 
    472      if (visit.date.getTime() < oldestAllowed) {
    473        // Visit is older than the oldest visit we have, and we have so many
    474        // visits for this uri that we hit our limit when inserting.
    475        continue;
    476      }
    477      let visitKey = `${visit.date.getTime()},${visit.type}`;
    478      if (curVisits.has(visitKey)) {
    479        // Visit is a dupe, don't increment 'k' so the element will be
    480        // overwritten.
    481        continue;
    482      }
    483 
    484      // Note the visit key, so that we don't add duplicate visits with
    485      // clamped timestamps.
    486      curVisits.add(visitKey);
    487 
    488      visit.transition = visit.type;
    489      k += 1;
    490    }
    491    record.visits.length = k; // truncate array
    492 
    493    // No update if there aren't any visits to apply.
    494    // History wants at least one visit.
    495    // In any case, the only thing we could change would be the title
    496    // and that shouldn't change without a visit.
    497    if (!record.visits.length) {
    498      this._log.trace(
    499        "Ignoring record " +
    500          record.id +
    501          " with URI " +
    502          record.uri.spec +
    503          ": no visits to add."
    504      );
    505      return null;
    506    }
    507 
    508    // PageInfo is validated using validateItemProperties which does a shallow
    509    // copy of the properties. Since record uses getters some of the properties
    510    // are not copied over. Thus we create and return a new object.
    511    let pageInfo = {
    512      title: record.title,
    513      url: record.url,
    514      guid: record.guid,
    515      visits: record.visits,
    516    };
    517 
    518    return pageInfo;
    519  },
    520 
    521  async remove(record) {
    522    this._log.trace("Removing page: " + record.id);
    523    let removed = await lazy.PlacesUtils.history.remove(record.id);
    524    if (removed) {
    525      this._log.trace("Removed page: " + record.id);
    526    } else {
    527      this._log.debug("Page already removed: " + record.id);
    528    }
    529  },
    530 
    531  async itemExists(id) {
    532    return !!(await lazy.PlacesSyncUtils.history.fetchURLInfoForGuid(id));
    533  },
    534 
    535  async createRecord(id, collection) {
    536    let foo = await lazy.PlacesSyncUtils.history.fetchURLInfoForGuid(id);
    537    let record = new HistoryRec(collection, id);
    538    if (foo) {
    539      record.histUri = foo.url;
    540      record.title = foo.title;
    541      record.sortindex = foo.frecency;
    542 
    543      // If we had any unknown fields, ensure we put it back on the
    544      // top-level record
    545      if (foo.unknownFields) {
    546        let unknownFields = JSON.parse(foo.unknownFields);
    547        Object.assign(record.cleartext, unknownFields);
    548      }
    549 
    550      try {
    551        record.visits = await lazy.PlacesSyncUtils.history.fetchVisitsForURL(
    552          record.histUri
    553        );
    554      } catch (e) {
    555        this._log.error(
    556          "Error while fetching visits for URL ${record.histUri}",
    557          record.histUri
    558        );
    559        record.visits = [];
    560      }
    561    } else {
    562      record.deleted = true;
    563    }
    564 
    565    return record;
    566  },
    567 
    568  async wipe() {
    569    return lazy.PlacesSyncUtils.history.wipe();
    570  },
    571 };
    572 Object.setPrototypeOf(HistoryStore.prototype, Store.prototype);
    573 
    574 function HistoryTracker(name, engine) {
    575  LegacyTracker.call(this, name, engine);
    576 }
    577 HistoryTracker.prototype = {
    578  onStart() {
    579    this._log.info("Adding Places observer.");
    580    this._placesObserver = new PlacesWeakCallbackWrapper(
    581      this.handlePlacesEvents.bind(this)
    582    );
    583    PlacesObservers.addListener(
    584      ["page-visited", "history-cleared", "page-removed"],
    585      this._placesObserver
    586    );
    587  },
    588 
    589  onStop() {
    590    this._log.info("Removing Places observer.");
    591    if (this._placesObserver) {
    592      PlacesObservers.removeListener(
    593        ["page-visited", "history-cleared", "page-removed"],
    594        this._placesObserver
    595      );
    596    }
    597  },
    598 
    599  QueryInterface: ChromeUtils.generateQI(["nsISupportsWeakReference"]),
    600 
    601  handlePlacesEvents(aEvents) {
    602    this.asyncObserver.enqueueCall(() => this._handlePlacesEvents(aEvents));
    603  },
    604 
    605  async _handlePlacesEvents(aEvents) {
    606    if (this.ignoreAll) {
    607      this._log.trace(
    608        "ignoreAll: ignoring visits [" +
    609          aEvents.map(v => v.guid).join(",") +
    610          "]"
    611      );
    612      return;
    613    }
    614    for (let event of aEvents) {
    615      switch (event.type) {
    616        case "page-visited": {
    617          this._log.trace("'page-visited': " + event.url);
    618          if (
    619            this.engine.shouldSyncURL(event.url) &&
    620            (await this.addChangedID(event.pageGuid))
    621          ) {
    622            this.score += SCORE_INCREMENT_SMALL;
    623          }
    624          break;
    625        }
    626        case "history-cleared": {
    627          this._log.trace("history-cleared");
    628          // Note that we're going to trigger a sync, but none of the cleared
    629          // pages are tracked, so the deletions will not be propagated.
    630          // See Bug 578694.
    631          this.score += SCORE_INCREMENT_XLARGE;
    632          break;
    633        }
    634        case "page-removed": {
    635          if (event.reason === PlacesVisitRemoved.REASON_EXPIRED) {
    636            return;
    637          }
    638 
    639          this._log.trace(
    640            "page-removed: " + event.url + ", reason " + event.reason
    641          );
    642          const added = await this.addChangedID(event.pageGuid);
    643          if (added) {
    644            this.score += event.isRemovedFromStore
    645              ? SCORE_INCREMENT_XLARGE
    646              : SCORE_INCREMENT_SMALL;
    647          }
    648          break;
    649        }
    650      }
    651    }
    652  },
    653 };
    654 Object.setPrototypeOf(HistoryTracker.prototype, LegacyTracker.prototype);