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);