/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { Log: "resource://gre/modules/Log.sys.mjs", PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", }); /** * This module exports functions for Sync to use when applying remote * records. The calls are similar to those in `Bookmarks.sys.mjs` and * `nsINavBookmarksService`, with special handling for * tags, keywords, synced annotations, and missing parents. */ export var PlacesSyncUtils = {}; const { SOURCE_SYNC } = Ci.nsINavBookmarksService; const MICROSECONDS_PER_SECOND = 1000000; const MOBILE_BOOKMARKS_PREF = "browser.bookmarks.showMobileBookmarks"; // These are defined as lazy getters to defer initializing the bookmarks // service until it's needed. ChromeUtils.defineLazyGetter(lazy, "ROOT_RECORD_ID_TO_GUID", () => ({ menu: lazy.PlacesUtils.bookmarks.menuGuid, places: lazy.PlacesUtils.bookmarks.rootGuid, tags: lazy.PlacesUtils.bookmarks.tagsGuid, toolbar: lazy.PlacesUtils.bookmarks.toolbarGuid, unfiled: lazy.PlacesUtils.bookmarks.unfiledGuid, mobile: lazy.PlacesUtils.bookmarks.mobileGuid, })); ChromeUtils.defineLazyGetter(lazy, "ROOT_GUID_TO_RECORD_ID", () => ({ [lazy.PlacesUtils.bookmarks.menuGuid]: "menu", [lazy.PlacesUtils.bookmarks.rootGuid]: "places", [lazy.PlacesUtils.bookmarks.tagsGuid]: "tags", [lazy.PlacesUtils.bookmarks.toolbarGuid]: "toolbar", [lazy.PlacesUtils.bookmarks.unfiledGuid]: "unfiled", [lazy.PlacesUtils.bookmarks.mobileGuid]: "mobile", })); ChromeUtils.defineLazyGetter(lazy, "ROOTS", () => Object.keys(lazy.ROOT_RECORD_ID_TO_GUID) ); // Gets the history transition values we ignore and do not sync, as a // string, which is a comma-separated set of values - ie, something which can // be used with sqlite's IN operator. Does *not* includes the parens. ChromeUtils.defineLazyGetter(lazy, "IGNORED_TRANSITIONS_AS_SQL_LIST", () => // * We don't sync `TRANSITION_FRAMED_LINK` visits - these are excluded when // rendering the history menu, so we use the same constraints for Sync. // * We don't sync `TRANSITION_DOWNLOAD` because it makes no sense to see // these on other devices - the downloaded file can not exist. // * We don't want to sync TRANSITION_EMBED visits, but these aren't // stored in the DB, so no need to specify them. // * 0 is invalid, and hopefully don't exist, but let's exclude it anyway. // Array.toString() semantics are well defined and exactly what we need, so.. [ 0, lazy.PlacesUtils.history.TRANSITION_FRAMED_LINK, lazy.PlacesUtils.history.TRANSITION_DOWNLOAD, ].toString() ); const HistorySyncUtils = (PlacesSyncUtils.history = Object.freeze({ SYNC_ID_META_KEY: "sync/history/syncId", LAST_SYNC_META_KEY: "sync/history/lastSync", /** * Returns the current history sync ID, or `""` if one isn't set. */ getSyncId() { return lazy.PlacesUtils.metadata.get(HistorySyncUtils.SYNC_ID_META_KEY, ""); }, /** * Assigns a new sync ID. This is called when we sync for the first time with * a new account, and when we're the first to sync after a node reassignment. * * @return {Promise} resolved once the ID has been updated. * @resolves to the new sync ID. */ resetSyncId() { return lazy.PlacesUtils.withConnectionWrapper( "HistorySyncUtils: resetSyncId", function (db) { let newSyncId = lazy.PlacesUtils.history.makeGuid(); return db.executeTransaction(async function () { await setHistorySyncId(db, newSyncId); return newSyncId; }); } ); }, /** * Ensures that the existing local sync ID, if any, is up-to-date with the * server. This is called when we sync with an existing account. * * @param newSyncId * The server's sync ID. * @return {Promise} resolved once the ID has been updated. */ async ensureCurrentSyncId(newSyncId) { if (!newSyncId || typeof newSyncId != "string") { throw new TypeError("Invalid new history sync ID"); } await lazy.PlacesUtils.withConnectionWrapper( "HistorySyncUtils: ensureCurrentSyncId", async function (db) { let existingSyncId = await lazy.PlacesUtils.metadata.getWithConnection( db, HistorySyncUtils.SYNC_ID_META_KEY, "" ); if (existingSyncId == newSyncId) { lazy.HistorySyncLog.trace("History sync ID up-to-date", { existingSyncId, }); return; } lazy.HistorySyncLog.info( "History sync ID changed; resetting metadata", { existingSyncId, newSyncId, } ); await db.executeTransaction(function () { return setHistorySyncId(db, newSyncId); }); } ); }, /** * Returns the last sync time, in seconds, for the history collection, or 0 * if history has never synced before. */ async getLastSync() { let lastSync = await lazy.PlacesUtils.metadata.get( HistorySyncUtils.LAST_SYNC_META_KEY, 0 ); return lastSync / 1000; }, /** * Updates the history collection last sync time. * * @param lastSyncSeconds * The collection last sync time, in seconds, as a number or string. */ async setLastSync(lastSyncSeconds) { let lastSync = Math.floor(lastSyncSeconds * 1000); if (!Number.isInteger(lastSync)) { throw new TypeError("Invalid history last sync timestamp"); } await lazy.PlacesUtils.metadata.set( HistorySyncUtils.LAST_SYNC_META_KEY, lastSync ); }, /** * Removes all history visits and pages from the database. Sync calls this * method when it receives a command from a remote client to wipe all stored * data. * * @return {Promise} resolved once all pages and visits have been removed. */ async wipe() { await lazy.PlacesUtils.history.clear(); await HistorySyncUtils.reset(); }, /** * Removes the sync ID and last sync time for the history collection. Unlike * `wipe`, this keeps all existing history pages and visits. * * @return {Promise} resolved once the metadata have been removed. */ reset() { return lazy.PlacesUtils.metadata.delete( HistorySyncUtils.SYNC_ID_META_KEY, HistorySyncUtils.LAST_SYNC_META_KEY ); }, /** * Clamps a history visit date between the current date and the earliest * sensible date. * * @param {Date} visitDate * The visit date. * @return {Date} The clamped visit date. */ clampVisitDate(visitDate) { let currentDate = new Date(); if (visitDate > currentDate) { return currentDate; } if (visitDate < BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP) { return new Date(BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP); } return visitDate; }, /** * Fetches the frecency for the URL provided * * @param url * @returns {Number} The frecency of the given url */ async fetchURLFrecency(url) { let canonicalURL = lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url); let db = await lazy.PlacesUtils.promiseDBConnection(); let rows = await db.executeCached( ` SELECT frecency FROM moz_places WHERE url_hash = hash(:url) AND url = :url LIMIT 1`, { url: canonicalURL.href } ); return rows.length ? rows[0].getResultByName("frecency") : -1; }, /** * Filters syncable places from a collection of places guids. * * @param guids * * @returns {Array} new Array with the guids that aren't syncable */ async determineNonSyncableGuids(guids) { // Filter out hidden pages and transitions that we don't sync. let db = await lazy.PlacesUtils.promiseDBConnection(); let nonSyncableGuids = []; for (let chunk of lazy.PlacesUtils.chunkArray(guids, db.variableLimit)) { let rows = await db.execute( ` SELECT DISTINCT p.guid FROM moz_places p JOIN moz_historyvisits v ON p.id = v.place_id WHERE p.guid IN (${new Array(chunk.length).fill("?").join(",")}) AND (p.hidden = 1 OR v.visit_type IN (${ lazy.IGNORED_TRANSITIONS_AS_SQL_LIST })) `, chunk ); nonSyncableGuids = nonSyncableGuids.concat( rows.map(row => row.getResultByName("guid")) ); } return nonSyncableGuids; }, /** * Change the guid of the given uri * * @param uri * @param guid */ changeGuid(uri, guid) { let canonicalURL = lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(uri); let validatedGuid = lazy.PlacesUtils.BOOKMARK_VALIDATORS.guid(guid); return lazy.PlacesUtils.withConnectionWrapper( "PlacesSyncUtils.history: changeGuid", async function (db) { await db.executeCached( ` UPDATE moz_places SET guid = :guid WHERE url_hash = hash(:page_url) AND url = :page_url`, { guid: validatedGuid, page_url: canonicalURL.href } ); } ); }, /** * Fetch the last 20 visits (date and type of it) corresponding to a given url * * @param url * @returns {Array} Each element of the Array is an object with members: date and type */ async fetchVisitsForURL(url) { let canonicalURL = lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url); let db = await lazy.PlacesUtils.promiseDBConnection(); let rows = await db.executeCached( ` SELECT visit_type type, visit_date date, json_extract(e.sync_json, '$.unknown_sync_fields') as unknownSyncFields FROM moz_historyvisits v JOIN moz_places h ON h.id = v.place_id LEFT OUTER JOIN moz_historyvisits_extra e ON e.visit_id = v.id WHERE url_hash = hash(:url) AND url = :url ORDER BY date DESC LIMIT 20`, { url: canonicalURL.href } ); return rows.map(row => { let visitDate = row.getResultByName("date"); let visitType = row.getResultByName("type"); let visit = { date: visitDate, type: visitType }; // We should grab unknown fields to roundtrip them // back to the server let unknownFields = row.getResultByName("unknownSyncFields"); if (unknownFields) { let unknownFieldsObj = JSON.parse(unknownFields); for (const key in unknownFieldsObj) { // We have to manually add it to the cleartext since that's // what gets processed during upload visit[key] = unknownFieldsObj[key]; } } return visit; }); }, /** * Fetches the guid of a uri * * @param uri * @returns {String} The guid of the given uri */ async fetchGuidForURL(url) { let canonicalURL = lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url); let db = await lazy.PlacesUtils.promiseDBConnection(); let rows = await db.executeCached( ` SELECT guid FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url`, { page_url: canonicalURL.href } ); if (!rows.length) { return null; } return rows[0].getResultByName("guid"); }, /** * Fetch information about a guid (url, title and frecency) * * @param guid * @returns {Object} Object with three members: url, title and frecency of the given guid */ async fetchURLInfoForGuid(guid) { let db = await lazy.PlacesUtils.promiseDBConnection(); let rows = await db.executeCached( ` SELECT url, IFNULL(title, '') AS title, frecency, json_extract(e.sync_json, '$.unknown_sync_fields') as unknownSyncFields FROM moz_places h LEFT OUTER JOIN moz_places_extra e ON e.place_id = h.id WHERE guid = :guid`, { guid } ); if (rows.length === 0) { return null; } let info = { url: rows[0].getResultByName("url"), title: rows[0].getResultByName("title"), frecency: rows[0].getResultByName("frecency"), }; let unknownFields = rows[0].getResultByName("unknownSyncFields"); if (unknownFields) { // This will be unfurled at the caller since the // cleartext process will drop this info.unknownFields = unknownFields; } return info; }, /** * Get all URLs filtered by the limit and since members of the options object. * * @param options * Options object with two members, since and limit. Both of them must be provided * @returns {Array} - Up to limit number of URLs starting from the date provided by since * * Note that some visit types are explicitly excluded - downloads and framed * links. */ async getAllURLs(options) { // Check that the limit property is finite number. if (!Number.isFinite(options.limit)) { throw new Error("The number provided in options.limit is not finite."); } // Check that the since property is of type Date. if ( !options.since || Object.prototype.toString.call(options.since) != "[object Date]" ) { throw new Error( "The property since of the options object must be of type Date." ); } let db = await lazy.PlacesUtils.promiseDBConnection(); let sinceInMicroseconds = lazy.PlacesUtils.toPRTime(options.since); let rows = await db.executeCached( ` SELECT DISTINCT p.url FROM moz_places p JOIN moz_historyvisits v ON p.id = v.place_id WHERE p.last_visit_date > :cutoff_date AND p.hidden = 0 AND v.visit_type NOT IN (${lazy.IGNORED_TRANSITIONS_AS_SQL_LIST}) ORDER BY frecency DESC LIMIT :max_results`, { cutoff_date: sinceInMicroseconds, max_results: options.limit } ); return rows.map(row => row.getResultByName("url")); }, /** * Insert or update the unknownFields that this client doesn't understand (yet) * but stores & roundtrips them to prevent other clients from losing that data * * @param updates array of objects * an update object needs to have either a: * placeId: if we're putting unknownFields for a moz_places item * visitId: if we're putting unknownFields for a moz_historyvisits item * Note: Supplying none or both will result in that record being ignored * unknownFields: the stringified json to insert */ async updateUnknownFieldsBatch(updates) { return lazy.PlacesUtils.withConnectionWrapper( "HistorySyncUtils: updateUnknownFieldsBatch", async function (db) { await db.executeTransaction(async () => { for await (const update of updates) { // Validate we only have one of these props if ( (update.placeId && update.visitId) || (!update.placeId && !update.visitId) ) { continue; } let tableName = update.placeId ? "moz_places_extra" : "moz_historyvisits_extra"; let keyName = update.placeId ? "place_id" : "visit_id"; await db.executeCached( ` INSERT INTO ${tableName} (${keyName}, sync_json) VALUES ( :keyValue, json_object('unknown_sync_fields', :unknownFields) ) ON CONFLICT(${keyName}) DO UPDATE SET sync_json=json_patch(${tableName}.sync_json, json_object('unknown_sync_fields',:unknownFields)) `, { keyValue: update.placeId ?? update.visitId, unknownFields: update.unknownFields, } ); } }); } ); }, // End of history freeze })); const BookmarkSyncUtils = (PlacesSyncUtils.bookmarks = Object.freeze({ SYNC_ID_META_KEY: "sync/bookmarks/syncId", LAST_SYNC_META_KEY: "sync/bookmarks/lastSync", WIPE_REMOTE_META_KEY: "sync/bookmarks/wipeRemote", // Jan 23, 1993 in milliseconds since 1970. Corresponds roughly to the release // of the original NCSA Mosiac. We can safely assume that any dates before // this time are invalid. EARLIEST_BOOKMARK_TIMESTAMP: Date.UTC(1993, 0, 23), KINDS: { BOOKMARK: "bookmark", QUERY: "query", FOLDER: "folder", LIVEMARK: "livemark", SEPARATOR: "separator", }, get ROOTS() { return lazy.ROOTS; }, /** * Returns the current bookmarks sync ID, or `""` if one isn't set. */ getSyncId() { return lazy.PlacesUtils.metadata.get( BookmarkSyncUtils.SYNC_ID_META_KEY, "" ); }, /** * Indicates if the bookmarks engine should erase all bookmarks on the server * and all other clients, because the user manually restored their bookmarks * from a backup on this client. */ async shouldWipeRemote() { let shouldWipeRemote = await lazy.PlacesUtils.metadata.get( BookmarkSyncUtils.WIPE_REMOTE_META_KEY, false ); return !!shouldWipeRemote; }, /** * Assigns a new sync ID, bumps the change counter, and flags all items as * "NEW" for upload. This is called when we sync for the first time with a * new account, when we're the first to sync after a node reassignment, and * on the first sync after a manual restore. * * @return {Promise} resolved once the ID and all items have been updated. * @resolves to the new sync ID. */ resetSyncId() { return lazy.PlacesUtils.withConnectionWrapper( "BookmarkSyncUtils: resetSyncId", function (db) { let newSyncId = lazy.PlacesUtils.history.makeGuid(); return db.executeTransaction(async function () { await setBookmarksSyncId(db, newSyncId); await resetAllSyncStatuses( db, lazy.PlacesUtils.bookmarks.SYNC_STATUS.NEW ); return newSyncId; }); } ); }, /** * Ensures that the existing local sync ID, if any, is up-to-date with the * server. This is called when we sync with an existing account. * * We always take the server's sync ID. If we don't have an existing ID, * we're either syncing for the first time with an existing account, or Places * has automatically restored from a backup. If the sync IDs don't match, * we're likely syncing after a node reassignment, where another client * uploaded their bookmarks first. * * @param newSyncId * The server's sync ID. * @return {Promise} resolved once the ID and all items have been updated. */ async ensureCurrentSyncId(newSyncId) { if (!newSyncId || typeof newSyncId != "string") { throw new TypeError("Invalid new bookmarks sync ID"); } await lazy.PlacesUtils.withConnectionWrapper( "BookmarkSyncUtils: ensureCurrentSyncId", async function (db) { let existingSyncId = await lazy.PlacesUtils.metadata.getWithConnection( db, BookmarkSyncUtils.SYNC_ID_META_KEY, "" ); // If we don't have a sync ID, take the server's without resetting // sync statuses. if (!existingSyncId) { lazy.BookmarkSyncLog.info("Taking new bookmarks sync ID", { newSyncId, }); await db.executeTransaction(() => setBookmarksSyncId(db, newSyncId)); return; } // If the existing sync ID matches the server, great! if (existingSyncId == newSyncId) { lazy.BookmarkSyncLog.trace("Bookmarks sync ID up-to-date", { existingSyncId, }); return; } // Otherwise, we have a sync ID, but it doesn't match, so we were likely // node reassigned. Take the server's sync ID and reset all items to // "UNKNOWN" so that we can merge. lazy.BookmarkSyncLog.info( "Bookmarks sync ID changed; resetting sync statuses", { existingSyncId, newSyncId } ); await db.executeTransaction(async function () { await setBookmarksSyncId(db, newSyncId); await resetAllSyncStatuses( db, lazy.PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN ); }); } ); }, /** * Returns the last sync time, in seconds, for the bookmarks collection, or 0 * if bookmarks have never synced before. */ async getLastSync() { let lastSync = await lazy.PlacesUtils.metadata.get( BookmarkSyncUtils.LAST_SYNC_META_KEY, 0 ); return lastSync / 1000; }, /** * Updates the bookmarks collection last sync time. * * @param lastSyncSeconds * The collection last sync time, in seconds, as a number or string. */ async setLastSync(lastSyncSeconds) { let lastSync = Math.floor(lastSyncSeconds * 1000); if (!Number.isInteger(lastSync)) { throw new TypeError("Invalid bookmarks last sync timestamp"); } await lazy.PlacesUtils.metadata.set( BookmarkSyncUtils.LAST_SYNC_META_KEY, lastSync ); }, /** * Resets Sync metadata for bookmarks in Places. This function behaves * differently depending on the change source, and may be called from * `PlacesSyncUtils.bookmarks.reset` or * `PlacesUtils.bookmarks.eraseEverything`. * * - RESTORE: The user is restoring from a backup. Drop the sync ID, last * sync time, and tombstones; reset sync statuses for remaining items to * "NEW"; then set a flag to wipe the server and all other clients. On the * next sync, we'll replace their bookmarks with ours. * * - RESTORE_ON_STARTUP: Places is automatically restoring from a backup to * recover from a corrupt database. The sync ID, last sync time, and * tombstones don't exist, since we don't back them up; reset sync statuses * for the roots to "UNKNOWN"; but don't wipe the server. On the next sync, * we'll merge the restored bookmarks with the ones on the server. * * - SYNC: Either another client told us to erase our bookmarks * (`PlacesSyncUtils.bookmarks.wipe`), or the user disconnected Sync * (`PlacesSyncUtils.bookmarks.reset`). In both cases, drop the existing * sync ID, last sync time, and tombstones; reset sync statuses for * remaining items to "NEW"; and don't wipe the server. * * @param db * the Sqlite.sys.mjs connection handle. * @param source * the change source constant. */ async resetSyncMetadata(db, source) { if ( ![ lazy.PlacesUtils.bookmarks.SOURCES.RESTORE, lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, lazy.PlacesUtils.bookmarks.SOURCES.SYNC, ].includes(source) ) { return; } // Remove the sync ID and last sync time in all cases. await lazy.PlacesUtils.metadata.deleteWithConnection( db, BookmarkSyncUtils.SYNC_ID_META_KEY, BookmarkSyncUtils.LAST_SYNC_META_KEY ); // If we're manually restoring from a backup, wipe the server and other // clients, so that we replace their bookmarks with the restored tree. If // we're automatically restoring to recover from a corrupt database, don't // wipe; we want to merge the restored tree with the one on the server. await lazy.PlacesUtils.metadata.setWithConnection( db, new Map([ [ BookmarkSyncUtils.WIPE_REMOTE_META_KEY, source == lazy.PlacesUtils.bookmarks.SOURCES.RESTORE, ], ]) ); // Reset change counters and sync statuses for roots and remaining // items, and drop tombstones. let syncStatus = source == lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP ? lazy.PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN : lazy.PlacesUtils.bookmarks.SYNC_STATUS.NEW; await resetAllSyncStatuses(db, syncStatus); }, /** * Converts a Places GUID to a Sync record ID. Record IDs are identical to * Places GUIDs for all items except roots. */ guidToRecordId(guid) { return lazy.ROOT_GUID_TO_RECORD_ID[guid] || guid; }, /** * Converts a Sync record ID to a Places GUID. */ recordIdToGuid(recordId) { return lazy.ROOT_RECORD_ID_TO_GUID[recordId] || recordId; }, /** * Fetches the record IDs for a folder's children, ordered by their position * within the folder. * Used only be tests - but that includes tps, so it lives here. */ fetchChildRecordIds(parentRecordId) { lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.recordId(parentRecordId); let parentGuid = BookmarkSyncUtils.recordIdToGuid(parentRecordId); return lazy.PlacesUtils.withConnectionWrapper( "BookmarkSyncUtils: fetchChildRecordIds", async function (db) { let childGuids = await fetchChildGuids(db, parentGuid); return childGuids.map(guid => BookmarkSyncUtils.guidToRecordId(guid)); } ); }, /** * Migrates an array of `{ recordId, modified }` tuples from the old JSON-based * tracker to the new sync change counter. `modified` is when the change was * added to the old tracker, in milliseconds. * * Sync calls this method before the first bookmark sync after the Places * schema migration. */ migrateOldTrackerEntries(entries) { return lazy.PlacesUtils.withConnectionWrapper( "BookmarkSyncUtils: migrateOldTrackerEntries", function (db) { return db.executeTransaction(async function () { // Mark all existing bookmarks as synced, and clear their change // counters to avoid a full upload on the next sync. Note that // this means we'll miss changes made between startup and the first // post-migration sync, as well as changes made on a new release // channel that weren't synced before the user downgraded. This is // unfortunate, but no worse than the behavior of the old tracker. // // We also likely have bookmarks that don't exist on the server, // because the old tracker missed them. We'll eventually fix the // server once we decide on a repair strategy. await db.executeCached( ` WITH RECURSIVE syncedItems(id) AS ( SELECT b.id FROM moz_bookmarks b WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____', 'mobile______') UNION ALL SELECT b.id FROM moz_bookmarks b JOIN syncedItems s ON b.parent = s.id ) UPDATE moz_bookmarks SET syncStatus = :syncStatus, syncChangeCounter = 0 WHERE id IN syncedItems`, { syncStatus: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL } ); await db.executeCached(`DELETE FROM moz_bookmarks_deleted`); await db.executeCached(`CREATE TEMP TABLE moz_bookmarks_tracked ( guid TEXT PRIMARY KEY, time INTEGER )`); try { for (let { recordId, modified } of entries) { let guid = BookmarkSyncUtils.recordIdToGuid(recordId); if (!lazy.PlacesUtils.isValidGuid(guid)) { lazy.BookmarkSyncLog.warn( `migrateOldTrackerEntries: Ignoring ` + `change for invalid item ${guid}` ); continue; } let time = lazy.PlacesUtils.toPRTime( Number.isFinite(modified) ? modified : Date.now() ); await db.executeCached( ` INSERT OR IGNORE INTO moz_bookmarks_tracked (guid, time) VALUES (:guid, :time)`, { guid, time } ); } // Bump the change counter for existing tracked items. await db.executeCached(` INSERT OR REPLACE INTO moz_bookmarks (id, fk, type, parent, position, title, dateAdded, lastModified, guid, syncChangeCounter, syncStatus) SELECT b.id, b.fk, b.type, b.parent, b.position, b.title, b.dateAdded, MAX(b.lastModified, t.time), b.guid, b.syncChangeCounter + 1, b.syncStatus FROM moz_bookmarks b JOIN moz_bookmarks_tracked t ON b.guid = t.guid`); // Insert tombstones for nonexistent tracked items, using the most // recent deletion date for more accurate reconciliation. We assume // the tracked item belongs to a synced root. await db.executeCached(` INSERT OR REPLACE INTO moz_bookmarks_deleted (guid, dateRemoved) SELECT t.guid, MAX(IFNULL((SELECT dateRemoved FROM moz_bookmarks_deleted WHERE guid = t.guid), 0), t.time) FROM moz_bookmarks_tracked t LEFT JOIN moz_bookmarks b ON t.guid = b.guid WHERE b.guid IS NULL`); } finally { await db.executeCached(`DROP TABLE moz_bookmarks_tracked`); } }); } ); }, /** * Reorders a folder's children, based on their order in the array of sync * IDs. * * Sync uses this method to reorder all synced children after applying all * incoming records. * * @return {Promise} resolved when reordering is complete. * @rejects if an error happens while reordering. * @throws if the arguments are invalid. */ order(parentRecordId, childRecordIds) { lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.recordId(parentRecordId); if (!childRecordIds.length) { return undefined; } let parentGuid = BookmarkSyncUtils.recordIdToGuid(parentRecordId); if (parentGuid == lazy.PlacesUtils.bookmarks.rootGuid) { // Reordering roots doesn't make sense, but Sync will do this on the // first sync. return undefined; } let orderedChildrenGuids = childRecordIds.map( BookmarkSyncUtils.recordIdToGuid ); return lazy.PlacesUtils.bookmarks.reorder( parentGuid, orderedChildrenGuids, { source: SOURCE_SYNC, } ); }, /** * Resolves to true if there are known sync changes. */ havePendingChanges() { return lazy.PlacesUtils.withConnectionWrapper( "BookmarkSyncUtils: havePendingChanges", async function (db) { let rows = await db.executeCached(` WITH RECURSIVE syncedItems(id, guid, syncChangeCounter) AS ( SELECT b.id, b.guid, b.syncChangeCounter FROM moz_bookmarks b WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____', 'mobile______') UNION ALL SELECT b.id, b.guid, b.syncChangeCounter FROM moz_bookmarks b JOIN syncedItems s ON b.parent = s.id ), changedItems(guid) AS ( SELECT guid FROM syncedItems WHERE syncChangeCounter >= 1 UNION ALL SELECT guid FROM moz_bookmarks_deleted ) SELECT EXISTS(SELECT guid FROM changedItems) AS haveChanges`); return !!rows[0].getResultByName("haveChanges"); } ); }, /** * Returns a changeset containing local bookmark changes since the last sync. * * @return {Promise} resolved once all items have been fetched. * @resolves to an object containing records for changed bookmarks, keyed by * the record ID. * @see pullSyncChanges for the implementation, and markChangesAsSyncing for * an explanation of why we update the sync status. */ pullChanges() { return lazy.PlacesUtils.withConnectionWrapper( "BookmarkSyncUtils: pullChanges", pullSyncChanges ); }, /** * Updates the sync status of all "NEW" bookmarks to "NORMAL", so that Sync * can recover correctly after an interrupted sync. * * @param changeRecords * A changeset containing sync change records, as returned by * `pullChanges`. * @return {Promise} resolved once all records have been updated. */ markChangesAsSyncing(changeRecords) { return lazy.PlacesUtils.withConnectionWrapper( "BookmarkSyncUtils: markChangesAsSyncing", db => markChangesAsSyncing(db, changeRecords) ); }, /** * Decrements the sync change counter, updates the sync status, and cleans up * tombstones for successfully synced items. Sync calls this method at the * end of each bookmark sync. * * @param changeRecords * A changeset containing sync change records, as returned by * `pullChanges`. * @return {Promise} resolved once all records have been updated. */ pushChanges(changeRecords) { return lazy.PlacesUtils.withConnectionWrapper( "BookmarkSyncUtils: pushChanges", async function (db) { let skippedCount = 0; let weakCount = 0; let updateParams = []; let tombstoneGuidsToRemove = []; for (let recordId in changeRecords) { // Validate change records to catch coding errors. let changeRecord = validateChangeRecord( "BookmarkSyncUtils: pushChanges", changeRecords[recordId], { tombstone: { required: true }, counter: { required: true }, synced: { required: true }, } ); // Skip weakly uploaded records. if (!changeRecord.counter) { weakCount++; continue; } // Sync sets the `synced` flag for reconciled or successfully // uploaded items. If upload failed, ignore the change; we'll // try again on the next sync. if (!changeRecord.synced) { skippedCount++; continue; } let guid = BookmarkSyncUtils.recordIdToGuid(recordId); if (changeRecord.tombstone) { tombstoneGuidsToRemove.push(guid); } else { updateParams.push({ guid, syncChangeDelta: changeRecord.counter, syncStatus: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, }); } } // Reduce the change counter and update the sync status for // reconciled and uploaded items. If the bookmark was updated // during the sync, its change counter will still be > 0 for the // next sync. if (updateParams.length || tombstoneGuidsToRemove.length) { await db.executeTransaction(async function () { if (updateParams.length) { await db.executeCached( ` UPDATE moz_bookmarks SET syncChangeCounter = MAX(syncChangeCounter - :syncChangeDelta, 0), syncStatus = :syncStatus WHERE guid = :guid`, updateParams ); // and if there are *both* bookmarks and tombstones for these // items, we nuke the tombstones. // This should be unlikely, but bad if it happens. let dupedGuids = updateParams.map(({ guid }) => guid); await removeUndeletedTombstones(db, dupedGuids); } await removeTombstones(db, tombstoneGuidsToRemove); }); } lazy.BookmarkSyncLog.debug(`pushChanges: Processed change records`, { weak: weakCount, skipped: skippedCount, updated: updateParams.length, }); } ); }, /** * Removes items from the database. Sync buffers incoming tombstones, and * calls this method to apply them at the end of each sync. Deletion * happens in three steps: * * 1. Remove all non-folder items. Deleting a folder on a remote client * uploads tombstones for the folder and its children at the time of * deletion. This preserves any new children we've added locally since * the last sync. * 2. Reparent remaining children to the tombstoned folder's parent. This * bumps the change counter for the children and their new parent. * 3. Remove the tombstoned folder. Because we don't do this in a * transaction, the user might move new items into the folder before we * can remove it. In that case, we keep the folder and upload the new * subtree to the server. * * See the comment above `BookmarksStore::deletePending` for the details on * why delete works the way it does. */ remove(recordIds) { if (!recordIds.length) { return null; } return lazy.PlacesUtils.withConnectionWrapper( "BookmarkSyncUtils: remove", async function (db) { let folderGuids = []; for (let recordId of recordIds) { if (recordId in lazy.ROOT_RECORD_ID_TO_GUID) { lazy.BookmarkSyncLog.warn( `remove: Refusing to remove root ${recordId}` ); continue; } let guid = BookmarkSyncUtils.recordIdToGuid(recordId); let bookmarkItem = await lazy.PlacesUtils.bookmarks.fetch(guid); if (!bookmarkItem) { lazy.BookmarkSyncLog.trace(`remove: Item ${guid} already removed`); continue; } let kind = await getKindForItem(db, bookmarkItem); if (kind == BookmarkSyncUtils.KINDS.FOLDER) { folderGuids.push(bookmarkItem.guid); continue; } let wasRemoved = await deleteSyncedAtom(bookmarkItem); if (wasRemoved) { lazy.BookmarkSyncLog.trace( `remove: Removed item ${guid} with kind ${kind}` ); } } for (let guid of folderGuids) { let bookmarkItem = await lazy.PlacesUtils.bookmarks.fetch(guid); if (!bookmarkItem) { lazy.BookmarkSyncLog.trace( `remove: Folder ${guid} already removed` ); continue; } let wasRemoved = await deleteSyncedFolder(db, bookmarkItem); if (wasRemoved) { lazy.BookmarkSyncLog.trace( `remove: Removed folder ${bookmarkItem.guid}` ); } } // TODO (Bug 1313890): Refactor the bookmarks engine to pull change records // before uploading, instead of returning records to merge into the engine's // initial changeset. return pullSyncChanges(db); } ); }, /** * Removes all bookmarks and tombstones from the database. Sync calls this * method when it receives a command from a remote client to wipe all stored * data. * * @return {Promise} resolved once all items have been removed. */ wipe() { return lazy.PlacesUtils.bookmarks.eraseEverything({ source: SOURCE_SYNC, }); }, /** * Marks all bookmarks as "NEW" and removes all tombstones. Unlike `wipe`, * this keeps all existing bookmarks, and only clears their sync change * tracking info. * * @return {Promise} resolved once all items have been updated. */ reset() { return lazy.PlacesUtils.withConnectionWrapper( "BookmarkSyncUtils: reset", function (db) { return db.executeTransaction(async function () { await BookmarkSyncUtils.resetSyncMetadata(db, SOURCE_SYNC); }); } ); }, /** * Fetches a Sync bookmark object for an item in the tree. * * Should only be used by SYNC TESTS. * We should remove this in bug XXXXXX, updating the tests to use * PlacesUtils.bookmarks.fetch. * * The object contains * the following properties, depending on the item's kind: * * - kind (all): A string representing the item's kind. * - recordId (all): The item's record ID. * - parentRecordId (all): The record ID of the item's parent. * - parentTitle (all): The title of the item's parent, used for de-duping. * Omitted for the Places root and parents with empty titles. * - dateAdded (all): Timestamp in milliseconds, when the bookmark was added * or created on a remote device if known. * - title ("bookmark", "folder", "query"): The item's title. * Omitted if empty. * - url ("bookmark", "query"): The item's URL. * - tags ("bookmark", "query"): An array containing the item's tags. * - keyword ("bookmark"): The bookmark's keyword, if one exists. * - childRecordIds ("folder"): An array containing the record IDs of the item's * children, used to determine child order. * - folder ("query"): The tag folder name, if this is a tag query. * - index ("separator"): The separator's position within its parent. */ async fetch(recordId) { let guid = BookmarkSyncUtils.recordIdToGuid(recordId); let bookmarkItem = await lazy.PlacesUtils.bookmarks.fetch(guid); if (!bookmarkItem) { return null; } return lazy.PlacesUtils.withConnectionWrapper( "BookmarkSyncUtils: fetch", async function (db) { // Convert the Places bookmark object to a Sync bookmark and add // kind-specific properties. Titles are required for bookmarks, // and folders; optional for queries, and omitted for separators. let kind = await getKindForItem(db, bookmarkItem); let item; switch (kind) { case BookmarkSyncUtils.KINDS.BOOKMARK: item = await fetchBookmarkItem(db, bookmarkItem); break; case BookmarkSyncUtils.KINDS.QUERY: item = await fetchQueryItem(db, bookmarkItem); break; case BookmarkSyncUtils.KINDS.FOLDER: item = await fetchFolderItem(db, bookmarkItem); break; case BookmarkSyncUtils.KINDS.SEPARATOR: item = await placesBookmarkToSyncBookmark(db, bookmarkItem); item.index = bookmarkItem.index; break; default: throw new Error(`Unknown bookmark kind: ${kind}`); } // Sync uses the parent title for de-duping. All Sync bookmark objects // except the Places root should have this property. if (bookmarkItem.parentGuid) { let parent = await lazy.PlacesUtils.bookmarks.fetch( bookmarkItem.parentGuid ); item.parentTitle = parent.title || ""; } return item; } ); }, /** * Returns the sync change counter increment for a change source constant. */ determineSyncChangeDelta(source) { // Don't bump the change counter when applying changes made by Sync, to // avoid sync loops. return source == lazy.PlacesUtils.bookmarks.SOURCES.SYNC ? 0 : 1; }, /** * Returns the sync status for a new item inserted by a change source. */ determineInitialSyncStatus(source) { if (source == lazy.PlacesUtils.bookmarks.SOURCES.SYNC) { // Incoming bookmarks are "NORMAL", since they already exist on the server. return lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL; } if (source == lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP) { // If the user restores from a backup, or Places automatically recovers // from a corrupt database, all prior sync tracking is lost. Setting the // status to "UNKNOWN" allows Sync to reconcile restored bookmarks with // those on the server. return lazy.PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN; } // For all other sources, mark items as "NEW". We'll update their statuses // to "NORMAL" after the first sync. return lazy.PlacesUtils.bookmarks.SYNC_STATUS.NEW; }, /** * An internal helper that bumps the change counter for all bookmarks with * a given URL. This is used to update bookmarks when adding or changing a * tag or keyword entry. * * @param db * the Sqlite.sys.mjs connection handle. * @param url * the bookmark URL object. * @param syncChangeDelta * the sync change counter increment. * @return {Promise} resolved when the counters have been updated. */ addSyncChangesForBookmarksWithURL(db, url, syncChangeDelta) { if (!url || !syncChangeDelta) { return Promise.resolve(); } return db.executeCached( ` UPDATE moz_bookmarks SET syncChangeCounter = syncChangeCounter + :syncChangeDelta WHERE type = :type AND fk = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url)`, { syncChangeDelta, type: lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK, url: url.href, } ); }, /** * Returns `0` if no sensible timestamp could be found. * Otherwise, returns the earliest sensible timestamp between `existingMillis` * and `serverMillis`. */ ratchetTimestampBackwards( existingMillis, serverMillis, lowerBound = BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP ) { const possible = [+existingMillis, +serverMillis].filter( n => !isNaN(n) && n > lowerBound ); if (!possible.length) { return 0; } return Math.min(...possible); }, /** * Rebuilds the left pane query for the mobile root under "All Bookmarks" if * necessary. Sync calls this method at the end of each bookmark sync. This * code should eventually move to `PlacesUIUtils#maybeRebuildLeftPane`; see * bug 647605. * * - If there are no mobile bookmarks, the query will not be created, or * will be removed if it already exists. * - If there are mobile bookmarks, the query will be created if it doesn't * exist, or will be updated with the correct title and URL otherwise. */ async ensureMobileQuery() { let db = await lazy.PlacesUtils.promiseDBConnection(); let mobileChildGuids = await fetchChildGuids( db, lazy.PlacesUtils.bookmarks.mobileGuid ); let hasMobileBookmarks = !!mobileChildGuids.length; Services.prefs.setBoolPref(MOBILE_BOOKMARKS_PREF, hasMobileBookmarks); }, })); PlacesSyncUtils.test = {}; PlacesSyncUtils.test.bookmarks = Object.freeze({ /** * Inserts a synced bookmark into the tree. Only SYNC TESTS should call this * method; other callers should use `PlacesUtils.bookmarks.insert`. * * It is in this file rather than a test-only file because it makes use of * other internal functions here, so moving is not trivial - see bug 1662602. * * The following properties are supported: * - kind: Required. * - guid: Required. * - parentGuid: Required. * - url: Required for bookmarks. * - tags: An optional array of tag strings. * - keyword: An optional keyword string. * * Sync doesn't set the index, since it appends and reorders children * after applying all incoming items. * * @param info * object representing a synced bookmark. * * @return {Promise} resolved when the creation is complete. * @resolves to an object representing the created bookmark. * @rejects if it's not possible to create the requested bookmark. * @throws if the arguments are invalid. */ insert(info) { let insertInfo = validateNewBookmark("BookmarkTestUtils: insert", info); return lazy.PlacesUtils.withConnectionWrapper( "BookmarkTestUtils: insert", async db => { // If we're inserting a tag query, make sure the tag exists and fix the // folder ID to refer to the local tag folder. insertInfo = await updateTagQueryFolder(db, insertInfo); let bookmarkInfo = syncBookmarkToPlacesBookmark(insertInfo); let bookmarkItem = await lazy.PlacesUtils.bookmarks.insert( bookmarkInfo ); let newItem = await insertBookmarkMetadata( db, bookmarkItem, insertInfo ); return newItem; } ); }, }); ChromeUtils.defineLazyGetter(lazy, "HistorySyncLog", () => { return lazy.Log.repository.getLogger("Sync.Engine.History.HistorySyncUtils"); }); ChromeUtils.defineLazyGetter(lazy, "BookmarkSyncLog", () => { // Use a sub-log of the bookmarks engine, so setting the level for that // engine also adjust the level of this log. return lazy.Log.repository.getLogger( "Sync.Engine.Bookmarks.BookmarkSyncUtils" ); }); function validateSyncBookmarkObject(name, input, behavior) { return lazy.PlacesUtils.validateItemProperties( name, lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS, input, behavior ); } // Validates a sync change record as returned by `pullChanges` and passed to // `pushChanges`. function validateChangeRecord(name, changeRecord, behavior) { return lazy.PlacesUtils.validateItemProperties( name, lazy.PlacesUtils.SYNC_CHANGE_RECORD_VALIDATORS, changeRecord, behavior ); } // Similar to the private `fetchBookmarksByParent` implementation in // `Bookmarks.sys.mjs`. var fetchChildGuids = async function (db, parentGuid) { let rows = await db.executeCached( ` SELECT guid FROM moz_bookmarks WHERE parent = ( SELECT id FROM moz_bookmarks WHERE guid = :parentGuid ) ORDER BY position`, { parentGuid } ); return rows.map(row => row.getResultByName("guid")); }; // Legacy tag queries may use a `place:` URL that refers to the tag folder ID. // When we apply a synced tag query from a remote client, we need to update the // URL to point to the local tag. function updateTagQueryFolder(db, info) { if ( info.kind != BookmarkSyncUtils.KINDS.QUERY || !info.folder || !info.url || info.url.protocol != "place:" ) { return info; } let params = new URLSearchParams(info.url.pathname); let type = +params.get("type"); if (type != Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) { return info; } lazy.BookmarkSyncLog.debug( `updateTagQueryFolder: Tag query folder: ${info.folder}` ); // Rewrite the query to directly reference the tag. params.delete("queryType"); params.delete("type"); params.delete("folder"); params.set("tag", info.folder); info.url = new URL(info.url.protocol + params); return info; } // Keywords are a 1 to 1 mapping between strings and pairs of (URL, postData). // (the postData is not synced, so we ignore it). Sync associates keywords with // bookmarks, which is not really accurate. -- We might already have a keyword // with that name, or we might already have another bookmark with that URL with // a different keyword, etc. // // If we don't handle those cases by removing the conflicting keywords first, // the insertion will fail, and the keywords will either be wrong, or missing. // This function handles those cases. function removeConflictingKeywords(bookmarkURL, newKeyword) { return lazy.PlacesUtils.withConnectionWrapper( "BookmarkSyncUtils: removeConflictingKeywords", async function (db) { let entryForURL = await lazy.PlacesUtils.keywords.fetch({ url: bookmarkURL.href, }); if (entryForURL && entryForURL.keyword !== newKeyword) { await lazy.PlacesUtils.keywords.remove({ keyword: entryForURL.keyword, source: SOURCE_SYNC, }); // This will cause us to reupload this record for this sync, but // without it, we will risk data corruption. await BookmarkSyncUtils.addSyncChangesForBookmarksWithURL( db, entryForURL.url, 1 ); } if (!newKeyword) { return; } let entryForNewKeyword = await lazy.PlacesUtils.keywords.fetch({ keyword: newKeyword, }); if (entryForNewKeyword) { await lazy.PlacesUtils.keywords.remove({ keyword: entryForNewKeyword.keyword, source: SOURCE_SYNC, }); await BookmarkSyncUtils.addSyncChangesForBookmarksWithURL( db, entryForNewKeyword.url, 1 ); } } ); } // Sets annotations, keywords, and tags on a new bookmark. Returns a Sync // bookmark object. async function insertBookmarkMetadata(db, bookmarkItem, insertInfo) { let newItem = await placesBookmarkToSyncBookmark(db, bookmarkItem); try { newItem.tags = tagItem(bookmarkItem, insertInfo.tags); } catch (ex) { lazy.BookmarkSyncLog.warn( `insertBookmarkMetadata: Error tagging item ${insertInfo.recordId}`, ex ); } if (insertInfo.keyword) { await removeConflictingKeywords(bookmarkItem.url, insertInfo.keyword); await lazy.PlacesUtils.keywords.insert({ keyword: insertInfo.keyword, url: bookmarkItem.url.href, source: SOURCE_SYNC, }); newItem.keyword = insertInfo.keyword; } return newItem; } // Determines the Sync record kind for an existing bookmark. async function getKindForItem(db, item) { switch (item.type) { case lazy.PlacesUtils.bookmarks.TYPE_FOLDER: { return BookmarkSyncUtils.KINDS.FOLDER; } case lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK: return item.url.protocol == "place:" ? BookmarkSyncUtils.KINDS.QUERY : BookmarkSyncUtils.KINDS.BOOKMARK; case lazy.PlacesUtils.bookmarks.TYPE_SEPARATOR: return BookmarkSyncUtils.KINDS.SEPARATOR; } return null; } // Returns the `nsINavBookmarksService` bookmark type constant for a Sync // record kind. function getTypeForKind(kind) { switch (kind) { case BookmarkSyncUtils.KINDS.BOOKMARK: case BookmarkSyncUtils.KINDS.QUERY: return lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK; case BookmarkSyncUtils.KINDS.FOLDER: return lazy.PlacesUtils.bookmarks.TYPE_FOLDER; case BookmarkSyncUtils.KINDS.SEPARATOR: return lazy.PlacesUtils.bookmarks.TYPE_SEPARATOR; } throw new Error(`Unknown bookmark kind: ${kind}`); } function validateNewBookmark(name, info) { let insertInfo = validateSyncBookmarkObject(name, info, { kind: { required: true }, recordId: { required: true }, url: { requiredIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK, BookmarkSyncUtils.KINDS.QUERY, ].includes(b.kind), validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK, BookmarkSyncUtils.KINDS.QUERY, ].includes(b.kind), }, parentRecordId: { required: true }, title: { validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK, BookmarkSyncUtils.KINDS.QUERY, BookmarkSyncUtils.KINDS.FOLDER, ].includes(b.kind) || b.title === "", }, query: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.QUERY }, folder: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.QUERY }, tags: { validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK, BookmarkSyncUtils.KINDS.QUERY, ].includes(b.kind), }, keyword: { validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK, BookmarkSyncUtils.KINDS.QUERY, ].includes(b.kind), }, dateAdded: { required: false }, }); return insertInfo; } function tagItem(item, tags) { if (!item.url) { return []; } // Remove leading and trailing whitespace, then filter out empty tags. let newTags = tags ? tags.map(tag => tag.trim()).filter(Boolean) : []; // Removing the last tagged item will also remove the tag. To preserve // tag IDs, we temporarily tag a dummy URI, ensuring the tags exist. let dummyURI = lazy.PlacesUtils.toURI("about:weave#BStore_tagURI"); let bookmarkURI = lazy.PlacesUtils.toURI(item.url); if (newTags && newTags.length) { lazy.PlacesUtils.tagging.tagURI(dummyURI, newTags, SOURCE_SYNC); } lazy.PlacesUtils.tagging.untagURI(bookmarkURI, null, SOURCE_SYNC); if (newTags && newTags.length) { lazy.PlacesUtils.tagging.tagURI(bookmarkURI, newTags, SOURCE_SYNC); } lazy.PlacesUtils.tagging.untagURI(dummyURI, null, SOURCE_SYNC); return newTags; } // Converts a Places bookmark to a Sync bookmark. This function maps Places // GUIDs to record IDs and filters out extra Places properties like date added, // last modified, and index. async function placesBookmarkToSyncBookmark(db, bookmarkItem) { let item = {}; for (let prop in bookmarkItem) { switch (prop) { // Record IDs are identical to Places GUIDs for all items except roots. case "guid": item.recordId = BookmarkSyncUtils.guidToRecordId(bookmarkItem.guid); break; case "parentGuid": item.parentRecordId = BookmarkSyncUtils.guidToRecordId( bookmarkItem.parentGuid ); break; // Sync uses kinds instead of types, which distinguish between folders, // livemarks, bookmarks, and queries. case "type": item.kind = await getKindForItem(db, bookmarkItem); break; case "title": case "url": item[prop] = bookmarkItem[prop]; break; case "dateAdded": item[prop] = new Date(bookmarkItem[prop]).getTime(); break; } } return item; } // Converts a Sync bookmark object to a Places bookmark or livemark object. // This function maps record IDs to Places GUIDs, and filters out extra Sync // properties like keywords, tags. Returns an object that can be passed to // `PlacesUtils.bookmarks.{insert, update}`. function syncBookmarkToPlacesBookmark(info) { let bookmarkInfo = { source: SOURCE_SYNC, }; for (let prop in info) { switch (prop) { case "kind": bookmarkInfo.type = getTypeForKind(info.kind); break; // Convert record IDs to Places GUIDs for roots. case "recordId": bookmarkInfo.guid = BookmarkSyncUtils.recordIdToGuid(info.recordId); break; case "dateAdded": bookmarkInfo.dateAdded = new Date(info.dateAdded); break; case "parentRecordId": bookmarkInfo.parentGuid = BookmarkSyncUtils.recordIdToGuid( info.parentRecordId ); // Instead of providing an index, Sync reorders children at the end of // the sync using `BookmarkSyncUtils.order`. We explicitly specify the // default index here to prevent `PlacesUtils.bookmarks.update` from // throwing. bookmarkInfo.index = lazy.PlacesUtils.bookmarks.DEFAULT_INDEX; break; case "title": case "url": bookmarkInfo[prop] = info[prop]; break; } } return bookmarkInfo; } // Creates and returns a Sync bookmark object containing the bookmark's // tags, keyword. var fetchBookmarkItem = async function (db, bookmarkItem) { let item = await placesBookmarkToSyncBookmark(db, bookmarkItem); if (!item.title) { item.title = ""; } item.tags = lazy.PlacesUtils.tagging.getTagsForURI( lazy.PlacesUtils.toURI(bookmarkItem.url) ); let keywordEntry = await lazy.PlacesUtils.keywords.fetch({ url: bookmarkItem.url, }); if (keywordEntry) { item.keyword = keywordEntry.keyword; } return item; }; // Creates and returns a Sync bookmark object containing the folder's children. async function fetchFolderItem(db, bookmarkItem) { let item = await placesBookmarkToSyncBookmark(db, bookmarkItem); if (!item.title) { item.title = ""; } let childGuids = await fetchChildGuids(db, bookmarkItem.guid); item.childRecordIds = childGuids.map(guid => BookmarkSyncUtils.guidToRecordId(guid) ); return item; } // Creates and returns a Sync bookmark object containing the query's tag // folder name. async function fetchQueryItem(db, bookmarkItem) { let item = await placesBookmarkToSyncBookmark(db, bookmarkItem); let params = new URLSearchParams(bookmarkItem.url.pathname); let tags = params.getAll("tag"); if (tags.length == 1) { item.folder = tags[0]; } return item; } function addRowToChangeRecords(row, changeRecords) { let guid = row.getResultByName("guid"); if (!guid) { throw new Error(`Changed item missing GUID`); } let isTombstone = !!row.getResultByName("tombstone"); let recordId = BookmarkSyncUtils.guidToRecordId(guid); if (recordId in changeRecords) { let existingRecord = changeRecords[recordId]; if (existingRecord.tombstone == isTombstone) { // Should never happen: `moz_bookmarks.guid` has a unique index, and // `moz_bookmarks_deleted.guid` is the primary key. throw new Error(`Duplicate item or tombstone ${recordId} in changeset`); } if (!existingRecord.tombstone && isTombstone) { // Don't replace undeleted items with tombstones... lazy.BookmarkSyncLog.warn( "addRowToChangeRecords: Ignoring tombstone for undeleted item", recordId ); return; } // ...But replace undeleted tombstones with items. lazy.BookmarkSyncLog.warn( "addRowToChangeRecords: Replacing tombstone for undeleted item", recordId ); } let modifiedAsPRTime = row.getResultByName("modified"); let modified = modifiedAsPRTime / MICROSECONDS_PER_SECOND; if (Number.isNaN(modified) || modified <= 0) { lazy.BookmarkSyncLog.error( "addRowToChangeRecords: Invalid modified date for " + recordId, modifiedAsPRTime ); modified = 0; } changeRecords[recordId] = { modified, counter: row.getResultByName("syncChangeCounter"), status: row.getResultByName("syncStatus"), tombstone: isTombstone, synced: false, }; } /** * Queries the database for synced bookmarks and tombstones, and returns a * changeset for the Sync bookmarks engine. * * @param db * The Sqlite.sys.mjs connection handle. * @param forGuids * Fetch Sync tracking information for only the requested GUIDs. * @return {Promise} resolved once all items have been fetched. * @resolves to an object containing records for changed bookmarks, keyed by * the record ID. */ var pullSyncChanges = async function (db, forGuids = []) { let changeRecords = {}; let itemConditions = ["syncChangeCounter >= 1"]; let tombstoneConditions = ["1 = 1"]; if (forGuids.length) { let restrictToGuids = `guid IN (${forGuids .map(guid => JSON.stringify(guid)) .join(",")})`; itemConditions.push(restrictToGuids); tombstoneConditions.push(restrictToGuids); } let rows = await db.executeCached( ` WITH RECURSIVE syncedItems(id, guid, modified, syncChangeCounter, syncStatus) AS ( SELECT b.id, b.guid, b.lastModified, b.syncChangeCounter, b.syncStatus FROM moz_bookmarks b WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____', 'mobile______') UNION ALL SELECT b.id, b.guid, b.lastModified, b.syncChangeCounter, b.syncStatus FROM moz_bookmarks b JOIN syncedItems s ON b.parent = s.id ) SELECT guid, modified, syncChangeCounter, syncStatus, 0 AS tombstone FROM syncedItems WHERE ${itemConditions.join(" AND ")} UNION ALL SELECT guid, dateRemoved AS modified, 1 AS syncChangeCounter, :deletedSyncStatus, 1 AS tombstone FROM moz_bookmarks_deleted WHERE ${tombstoneConditions.join(" AND ")}`, { deletedSyncStatus: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL } ); for (let row of rows) { addRowToChangeRecords(row, changeRecords); } return changeRecords; }; // Moves a synced folder's remaining children to its parent, and deletes the // folder if it's empty. async function deleteSyncedFolder(db, bookmarkItem) { // At this point, any member in the folder that remains is either a folder // pending deletion (which we'll get to in this function), or an item that // should not be deleted. To avoid deleting these items, we first move them // to the parent of the folder we're about to delete. let childGuids = await fetchChildGuids(db, bookmarkItem.guid); if (!childGuids.length) { // No children -- just delete the folder. return deleteSyncedAtom(bookmarkItem); } if (lazy.BookmarkSyncLog.level <= lazy.Log.Level.Trace) { lazy.BookmarkSyncLog.trace( `deleteSyncedFolder: Moving ${JSON.stringify(childGuids)} children of ` + `"${bookmarkItem.guid}" to grandparent "${BookmarkSyncUtils.guidToRecordId(bookmarkItem.parentGuid)}" before ` + `deletion` ); } // Move children out of the parent and into the grandparent for (let guid of childGuids) { await lazy.PlacesUtils.bookmarks.update({ guid, parentGuid: bookmarkItem.parentGuid, index: lazy.PlacesUtils.bookmarks.DEFAULT_INDEX, // `SYNC_REPARENT_REMOVED_FOLDER_CHILDREN` bumps the change counter for // the child and its new parent, without incrementing the bookmark // tracker's score. // // We intentionally don't check if the child is one we'll remove later, // so it's possible we'll bump the change counter of the closest living // ancestor when it's not needed. This avoids inconsistency if removal // is interrupted, since we don't run this operation in a transaction. source: lazy.PlacesUtils.bookmarks.SOURCES .SYNC_REPARENT_REMOVED_FOLDER_CHILDREN, }); } // Delete the (now empty) parent try { await lazy.PlacesUtils.bookmarks.remove(bookmarkItem.guid, { preventRemovalOfNonEmptyFolders: true, // We don't want to bump the change counter for this deletion, because // a tombstone for the folder is already on the server. source: SOURCE_SYNC, }); } catch (e) { // We failed, probably because someone added something to this folder // between when we got the children and now (or the database is corrupt, // or something else happened...) This is unlikely, but possible. To // avoid corruption in this case, we need to reupload the record to the // server. // // (Ideally this whole operation would be done in a transaction, and this // wouldn't be possible). lazy.BookmarkSyncLog.trace( `deleteSyncedFolder: Error removing parent ` + `${bookmarkItem.guid} after reparenting children`, e ); return false; } return true; } // Removes a synced bookmark or empty folder from the database. var deleteSyncedAtom = async function (bookmarkItem) { try { await lazy.PlacesUtils.bookmarks.remove(bookmarkItem.guid, { preventRemovalOfNonEmptyFolders: true, source: SOURCE_SYNC, }); } catch (ex) { // Likely already removed. lazy.BookmarkSyncLog.trace( `deleteSyncedAtom: Error removing ` + bookmarkItem.guid, ex ); return false; } return true; }; /** * Updates the sync status on all "NEW" and "UNKNOWN" bookmarks to "NORMAL". * * We do this when pulling changes instead of in `pushChanges` to make sure * we write tombstones if a new item is deleted after an interrupted sync. (For * example, if a "NEW" record is uploaded or reconciled, then the app is closed * before Sync calls `pushChanges`). */ function markChangesAsSyncing(db, changeRecords) { let unsyncedGuids = []; for (let recordId in changeRecords) { if (changeRecords[recordId].tombstone) { continue; } if ( changeRecords[recordId].status == lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL ) { continue; } let guid = BookmarkSyncUtils.recordIdToGuid(recordId); unsyncedGuids.push(JSON.stringify(guid)); } if (!unsyncedGuids.length) { return Promise.resolve(); } return db.execute( ` UPDATE moz_bookmarks SET syncStatus = :syncStatus WHERE guid IN (${unsyncedGuids.join(",")})`, { syncStatus: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL } ); } /** * Removes tombstones for successfully synced items. * * @return {Promise} */ var removeTombstones = function (db, guids) { if (!guids.length) { return Promise.resolve(); } return db.execute(` DELETE FROM moz_bookmarks_deleted WHERE guid IN (${guids.map(guid => JSON.stringify(guid)).join(",")})`); }; /** * Removes tombstones for successfully synced items where the specified GUID * exists in *both* the bookmarks and tombstones tables. * * @return {Promise} */ var removeUndeletedTombstones = function (db, guids) { if (!guids.length) { return Promise.resolve(); } // sqlite can't join in a DELETE, so we use a subquery. return db.execute(` DELETE FROM moz_bookmarks_deleted WHERE guid IN (${guids.map(guid => JSON.stringify(guid)).join(",")}) AND guid IN (SELECT guid from moz_bookmarks)`); }; // Sets the history sync ID and clears the last sync time. async function setHistorySyncId(db, newSyncId) { await lazy.PlacesUtils.metadata.setWithConnection( db, new Map([[HistorySyncUtils.SYNC_ID_META_KEY, newSyncId]]) ); await lazy.PlacesUtils.metadata.deleteWithConnection( db, HistorySyncUtils.LAST_SYNC_META_KEY ); } // Sets the bookmarks sync ID and clears the last sync time. async function setBookmarksSyncId(db, newSyncId) { await lazy.PlacesUtils.metadata.setWithConnection( db, new Map([[BookmarkSyncUtils.SYNC_ID_META_KEY, newSyncId]]) ); await lazy.PlacesUtils.metadata.deleteWithConnection( db, BookmarkSyncUtils.LAST_SYNC_META_KEY, BookmarkSyncUtils.WIPE_REMOTE_META_KEY ); } // Bumps the change counter and sets the given sync status for all bookmarks, // and drops stale tombstones. async function resetAllSyncStatuses(db, syncStatus) { await db.execute( ` UPDATE moz_bookmarks SET syncChangeCounter = 1, syncStatus = :syncStatus`, { syncStatus } ); // Drop stale tombstones. await db.execute("DELETE FROM moz_bookmarks_deleted"); } /** * Other clients might have new fields we don't quite understand yet, * so we add it to a "unknownFields" field to roundtrip back to the server * so other clients don't experience data loss * @param record: an object, usually from the server, and will iterate through the * the keys and extract any fields that are unknown to this client * @param validFields: an array of keys we know are valid and should ignore * @returns {String} json object containing unknownfields, null if none found */ PlacesSyncUtils.extractUnknownFields = (record, validFields) => { let { unknownFields, hasUnknownFields } = Object.keys(record).reduce( ({ unknownFields, hasUnknownFields }, key) => { if (validFields.includes(key)) { return { unknownFields, hasUnknownFields }; } unknownFields[key] = record[key]; return { unknownFields, hasUnknownFields: true }; }, { unknownFields: {}, hasUnknownFields: false } ); if (hasUnknownFields) { // For simplicity, we store the unknown fields as a string // since we never operate on it and just need it for roundtripping return JSON.stringify(unknownFields); } return null; };