diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/components/places/PlacesSyncUtils.sys.mjs | 2098 |
1 files changed, 2098 insertions, 0 deletions
diff --git a/toolkit/components/places/PlacesSyncUtils.sys.mjs b/toolkit/components/places/PlacesSyncUtils.sys.mjs new file mode 100644 index 0000000000..6da1b91243 --- /dev/null +++ b/toolkit/components/places/PlacesSyncUtils.sys.mjs @@ -0,0 +1,2098 @@ +/* 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.jsm` 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.jsm`. +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; +}; |