diff options
Diffstat (limited to 'services/sync/modules')
28 files changed, 1602 insertions, 5801 deletions
diff --git a/services/sync/modules/SyncedTabs.jsm b/services/sync/modules/SyncedTabs.jsm deleted file mode 100644 index 1a69e3564..000000000 --- a/services/sync/modules/SyncedTabs.jsm +++ /dev/null @@ -1,301 +0,0 @@ -/* 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/. */ - -"use strict"; - -this.EXPORTED_SYMBOLS = ["SyncedTabs"]; - - -const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; - -Cu.import("resource://gre/modules/Services.jsm"); -Cu.import("resource://gre/modules/XPCOMUtils.jsm"); -Cu.import("resource://gre/modules/Task.jsm"); -Cu.import("resource://gre/modules/Log.jsm"); -Cu.import("resource://gre/modules/PlacesUtils.jsm", this); -Cu.import("resource://services-sync/main.js"); -Cu.import("resource://gre/modules/Preferences.jsm"); - -// The Sync XPCOM service -XPCOMUtils.defineLazyGetter(this, "weaveXPCService", function() { - return Cc["@mozilla.org/weave/service;1"] - .getService(Ci.nsISupports) - .wrappedJSObject; -}); - -// from MDN... -function escapeRegExp(string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -// A topic we fire whenever we have new tabs available. This might be due -// to a request made by this module to refresh the tab list, or as the result -// of a regularly scheduled sync. The intent is that consumers just listen -// for this notification and update their UI in response. -const TOPIC_TABS_CHANGED = "services.sync.tabs.changed"; - -// The interval, in seconds, before which we consider the existing list -// of tabs "fresh enough" and don't force a new sync. -const TABS_FRESH_ENOUGH_INTERVAL = 30; - -let log = Log.repository.getLogger("Sync.RemoteTabs"); -// A new scope to do the logging thang... -(function() { - let level = Preferences.get("services.sync.log.logger.tabs"); - if (level) { - let appender = new Log.DumpAppender(); - log.level = appender.level = Log.Level[level] || Log.Level.Debug; - log.addAppender(appender); - } -})(); - - -// A private singleton that does the work. -let SyncedTabsInternal = { - /* Make a "tab" record. Returns a promise */ - _makeTab: Task.async(function* (client, tab, url, showRemoteIcons) { - let icon; - if (showRemoteIcons) { - icon = tab.icon; - } - if (!icon) { - try { - icon = (yield PlacesUtils.promiseFaviconLinkUrl(url)).spec; - } catch (ex) { /* no favicon avaiable */ } - } - if (!icon) { - icon = ""; - } - return { - type: "tab", - title: tab.title || url, - url, - icon, - client: client.id, - lastUsed: tab.lastUsed, - }; - }), - - /* Make a "client" record. Returns a promise for consistency with _makeTab */ - _makeClient: Task.async(function* (client) { - return { - id: client.id, - type: "client", - name: Weave.Service.clientsEngine.getClientName(client.id), - isMobile: Weave.Service.clientsEngine.isMobile(client.id), - lastModified: client.lastModified * 1000, // sec to ms - tabs: [] - }; - }), - - _tabMatchesFilter(tab, filter) { - let reFilter = new RegExp(escapeRegExp(filter), "i"); - return tab.url.match(reFilter) || tab.title.match(reFilter); - }, - - getTabClients: Task.async(function* (filter) { - log.info("Generating tab list with filter", filter); - let result = []; - - // If Sync isn't ready, don't try and get anything. - if (!weaveXPCService.ready) { - log.debug("Sync isn't yet ready, so returning an empty tab list"); - return result; - } - - // A boolean that controls whether we should show the icon from the remote tab. - const showRemoteIcons = Preferences.get("services.sync.syncedTabs.showRemoteIcons", true); - - let engine = Weave.Service.engineManager.get("tabs"); - - let seenURLs = new Set(); - let parentIndex = 0; - let ntabs = 0; - - for (let [guid, client] of Object.entries(engine.getAllClients())) { - if (!Weave.Service.clientsEngine.remoteClientExists(client.id)) { - continue; - } - let clientRepr = yield this._makeClient(client); - log.debug("Processing client", clientRepr); - - for (let tab of client.tabs) { - let url = tab.urlHistory[0]; - log.debug("remote tab", url); - // Note there are some issues with tracking "seen" tabs, including: - // * We really can't return the entire urlHistory record as we are - // only checking the first entry - others might be different. - // * We don't update the |lastUsed| timestamp to reflect the - // most-recently-seen time. - // In a followup we should consider simply dropping this |seenUrls| - // check and return duplicate records - it seems the user will be more - // confused by tabs not showing up on a device (because it was detected - // as a dupe so it only appears on a different device) than being - // confused by seeing the same tab on different clients. - if (!url || seenURLs.has(url)) { - continue; - } - let tabRepr = yield this._makeTab(client, tab, url, showRemoteIcons); - if (filter && !this._tabMatchesFilter(tabRepr, filter)) { - continue; - } - seenURLs.add(url); - clientRepr.tabs.push(tabRepr); - } - // We return all clients, even those without tabs - the consumer should - // filter it if they care. - ntabs += clientRepr.tabs.length; - result.push(clientRepr); - } - log.info(`Final tab list has ${result.length} clients with ${ntabs} tabs.`); - return result; - }), - - syncTabs(force) { - if (!force) { - // Don't bother refetching tabs if we already did so recently - let lastFetch = Preferences.get("services.sync.lastTabFetch", 0); - let now = Math.floor(Date.now() / 1000); - if (now - lastFetch < TABS_FRESH_ENOUGH_INTERVAL) { - log.info("_refetchTabs was done recently, do not doing it again"); - return Promise.resolve(false); - } - } - - // If Sync isn't configured don't try and sync, else we will get reports - // of a login failure. - if (Weave.Status.checkSetup() == Weave.CLIENT_NOT_CONFIGURED) { - log.info("Sync client is not configured, so not attempting a tab sync"); - return Promise.resolve(false); - } - // Ask Sync to just do the tabs engine if it can. - // Sync is currently synchronous, so do it after an event-loop spin to help - // keep the UI responsive. - return new Promise((resolve, reject) => { - Services.tm.currentThread.dispatch(() => { - try { - log.info("Doing a tab sync."); - Weave.Service.sync(["tabs"]); - resolve(true); - } catch (ex) { - log.error("Sync failed", ex); - reject(ex); - }; - }, Ci.nsIThread.DISPATCH_NORMAL); - }); - }, - - observe(subject, topic, data) { - log.trace(`observed topic=${topic}, data=${data}, subject=${subject}`); - switch (topic) { - case "weave:engine:sync:finish": - if (data != "tabs") { - return; - } - // The tabs engine just finished syncing - // Set our lastTabFetch pref here so it tracks both explicit sync calls - // and normally scheduled ones. - Preferences.set("services.sync.lastTabFetch", Math.floor(Date.now() / 1000)); - Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED, null); - break; - case "weave:service:start-over": - // start-over needs to notify so consumers find no tabs. - Preferences.reset("services.sync.lastTabFetch"); - Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED, null); - break; - case "nsPref:changed": - Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED, null); - break; - default: - break; - } - }, - - // Returns true if Sync is configured to Sync tabs, false otherwise - get isConfiguredToSyncTabs() { - if (!weaveXPCService.ready) { - log.debug("Sync isn't yet ready; assuming tab engine is enabled"); - return true; - } - - let engine = Weave.Service.engineManager.get("tabs"); - return engine && engine.enabled; - }, - - get hasSyncedThisSession() { - let engine = Weave.Service.engineManager.get("tabs"); - return engine && engine.hasSyncedThisSession; - }, -}; - -Services.obs.addObserver(SyncedTabsInternal, "weave:engine:sync:finish", false); -Services.obs.addObserver(SyncedTabsInternal, "weave:service:start-over", false); -// Observe the pref the indicates the state of the tabs engine has changed. -// This will force consumers to re-evaluate the state of sync and update -// accordingly. -Services.prefs.addObserver("services.sync.engine.tabs", SyncedTabsInternal, false); - -// The public interface. -this.SyncedTabs = { - // A mock-point for tests. - _internal: SyncedTabsInternal, - - // We make the topic for the observer notification public. - TOPIC_TABS_CHANGED, - - // Returns true if Sync is configured to Sync tabs, false otherwise - get isConfiguredToSyncTabs() { - return this._internal.isConfiguredToSyncTabs; - }, - - // Returns true if a tab sync has completed once this session. If this - // returns false, then getting back no clients/tabs possibly just means we - // are waiting for that first sync to complete. - get hasSyncedThisSession() { - return this._internal.hasSyncedThisSession; - }, - - // Return a promise that resolves with an array of client records, each with - // a .tabs array. Note that part of the contract for this module is that the - // returned objects are not shared between invocations, so callers are free - // to mutate the returned objects (eg, sort, truncate) however they see fit. - getTabClients(query) { - return this._internal.getTabClients(query); - }, - - // Starts a background request to start syncing tabs. Returns a promise that - // resolves when the sync is complete, but there's no resolved value - - // callers should be listening for TOPIC_TABS_CHANGED. - // If |force| is true we always sync. If false, we only sync if the most - // recent sync wasn't "recently". - syncTabs(force) { - return this._internal.syncTabs(force); - }, - - sortTabClientsByLastUsed(clients, maxTabs = Infinity) { - // First sort and filter the list of tabs for each client. Note that - // this module promises that the objects it returns are never - // shared, so we are free to mutate those objects directly. - for (let client of clients) { - let tabs = client.tabs; - tabs.sort((a, b) => b.lastUsed - a.lastUsed); - if (Number.isFinite(maxTabs)) { - client.tabs = tabs.slice(0, maxTabs); - } - } - // Now sort the clients - the clients are sorted in the order of the - // most recent tab for that client (ie, it is important the tabs for - // each client are already sorted.) - clients.sort((a, b) => { - if (a.tabs.length == 0) { - return 1; // b comes first. - } - if (b.tabs.length == 0) { - return -1; // a comes first. - } - return b.tabs[0].lastUsed - a.tabs[0].lastUsed; - }); - }, -}; - diff --git a/services/sync/modules/addonsreconciler.js b/services/sync/modules/addonsreconciler.js index a60fc8d56..ec0896bb2 100644 --- a/services/sync/modules/addonsreconciler.js +++ b/services/sync/modules/addonsreconciler.js @@ -434,8 +434,7 @@ AddonsReconciler.prototype = { modified: now, type: addon.type, scope: addon.scope, - foreignInstall: addon.foreignInstall, - isSyncable: addon.isSyncable, + foreignInstall: addon.foreignInstall }; this._addons[id] = record; this._log.debug("Adding change because add-on not present locally: " + @@ -445,7 +444,6 @@ AddonsReconciler.prototype = { } let record = this._addons[id]; - record.isSyncable = addon.isSyncable; if (!record.installed) { // It is possible the record is marked as uninstalled because an @@ -490,7 +488,7 @@ AddonsReconciler.prototype = { try { listener.changeListener.call(listener, date, change, state); } catch (ex) { - this._log.warn("Exception calling change listener", ex); + this._log.warn("Exception calling change listener: ", ex); } } }, @@ -636,7 +634,7 @@ AddonsReconciler.prototype = { } } catch (ex) { - this._log.warn("Exception", ex); + this._log.warn("Exception: ", ex); } }, diff --git a/services/sync/modules/addonutils.js b/services/sync/modules/addonutils.js index 95da6be0a..3332f4cfc 100644 --- a/services/sync/modules/addonutils.js +++ b/services/sync/modules/addonutils.js @@ -38,10 +38,21 @@ AddonUtilsInternal.prototype = { * Function to be called with result of operation. */ getInstallFromSearchResult: - function getInstallFromSearchResult(addon, cb) { + function getInstallFromSearchResult(addon, cb, requireSecureURI=true) { this._log.debug("Obtaining install for " + addon.id); + // Verify that the source URI uses TLS. We don't allow installs from + // insecure sources for security reasons. The Addon Manager ensures that + // cert validation, etc is performed. + if (requireSecureURI) { + let scheme = addon.sourceURI.scheme; + if (scheme != "https") { + cb(new Error("Insecure source URI scheme: " + scheme), addon.install); + return; + } + } + // We should theoretically be able to obtain (and use) addon.install if // it is available. However, the addon.sourceURI rewriting won't be // reflected in the AddonInstall, so we can't use it. If we ever get rid @@ -69,6 +80,8 @@ AddonUtilsInternal.prototype = { * syncGUID - Sync GUID to use for the new add-on. * enabled - Boolean indicating whether the add-on should be enabled upon * install. + * requireSecureURI - Boolean indicating whether to require a secure + * URI to install from. This defaults to true. * * When complete it calls a callback with 2 arguments, error and result. * @@ -92,6 +105,10 @@ AddonUtilsInternal.prototype = { function installAddonFromSearchResult(addon, options, cb) { this._log.info("Trying to install add-on from search result: " + addon.id); + if (options.requireSecureURI === undefined) { + options.requireSecureURI = true; + } + this.getInstallFromSearchResult(addon, function onResult(error, install) { if (error) { cb(error, null); @@ -147,10 +164,10 @@ AddonUtilsInternal.prototype = { install.install(); } catch (ex) { - this._log.error("Error installing add-on", ex); + this._log.error("Error installing add-on: ", ex); cb(ex, null); } - }.bind(this)); + }.bind(this), options.requireSecureURI); }, /** @@ -244,7 +261,6 @@ AddonUtilsInternal.prototype = { installedIDs: [], installs: [], addons: [], - skipped: [], errors: [] }; @@ -283,20 +299,14 @@ AddonUtilsInternal.prototype = { // ideally send proper URLs, but this solution was deemed too // complicated at the time the functionality was implemented. for (let addon of addons) { - // Find the specified options for this addon. - let options; - for (let install of installs) { - if (install.id == addon.id) { - options = install; - break; - } - } - if (!this.canInstallAddon(addon, options)) { - ourResult.skipped.push(addon.id); + // sourceURI presence isn't enforced by AddonRepository. So, we skip + // add-ons without a sourceURI. + if (!addon.sourceURI) { + this._log.info("Skipping install of add-on because missing " + + "sourceURI: " + addon.id); continue; } - // We can go ahead and attempt to install it. toInstall.push(addon); // We should always be able to QI the nsIURI to nsIURL. If not, we @@ -353,48 +363,6 @@ AddonUtilsInternal.prototype = { }, /** - * Returns true if we are able to install the specified addon, false - * otherwise. It is expected that this will log the reason if it returns - * false. - * - * @param addon - * (Addon) Add-on instance to check. - * @param options - * (object) The options specified for this addon. See installAddons() - * for the valid elements. - */ - canInstallAddon(addon, options) { - // sourceURI presence isn't enforced by AddonRepository. So, we skip - // add-ons without a sourceURI. - if (!addon.sourceURI) { - this._log.info("Skipping install of add-on because missing " + - "sourceURI: " + addon.id); - return false; - } - // Verify that the source URI uses TLS. We don't allow installs from - // insecure sources for security reasons. The Addon Manager ensures - // that cert validation etc is performed. - // (We should also consider just dropping this entirely and calling - // XPIProvider.isInstallAllowed, but that has additional semantics we might - // need to think through...) - let requireSecureURI = true; - if (options && options.requireSecureURI !== undefined) { - requireSecureURI = options.requireSecureURI; - } - - if (requireSecureURI) { - let scheme = addon.sourceURI.scheme; - if (scheme != "https") { - this._log.info(`Skipping install of add-on "${addon.id}" because sourceURI's scheme of "${scheme}" is not trusted`); - return false; - } - } - this._log.info(`Add-on "${addon.id}" is able to be installed`); - return true; - }, - - - /** * Update the user disabled flag for an add-on. * * The supplied callback will be called when the operation is diff --git a/services/sync/modules/bookmark_validator.js b/services/sync/modules/bookmark_validator.js deleted file mode 100644 index 2a94ba043..000000000 --- a/services/sync/modules/bookmark_validator.js +++ /dev/null @@ -1,784 +0,0 @@ -/* 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/. */ - -"use strict"; - -const Cu = Components.utils; - -Cu.import("resource://gre/modules/PlacesUtils.jsm"); -Cu.import("resource://gre/modules/PlacesSyncUtils.jsm"); -Cu.import("resource://gre/modules/Task.jsm"); -Cu.import("resource://gre/modules/XPCOMUtils.jsm"); - - -this.EXPORTED_SYMBOLS = ["BookmarkValidator", "BookmarkProblemData"]; - -const LEFT_PANE_ROOT_ANNO = "PlacesOrganizer/OrganizerFolder"; -const LEFT_PANE_QUERY_ANNO = "PlacesOrganizer/OrganizerQuery"; - -// Indicates if a local bookmark tree node should be excluded from syncing. -function isNodeIgnored(treeNode) { - return treeNode.annos && treeNode.annos.some(anno => anno.name == LEFT_PANE_ROOT_ANNO || - anno.name == LEFT_PANE_QUERY_ANNO); -} -const BOOKMARK_VALIDATOR_VERSION = 1; - -/** - * Result of bookmark validation. Contains the following fields which describe - * server-side problems unless otherwise specified. - * - * - missingIDs (number): # of objects with missing ids - * - duplicates (array of ids): ids seen more than once - * - parentChildMismatches (array of {parent: parentid, child: childid}): - * instances where the child's parentid and the parent's children array - * do not match - * - cycles (array of array of ids). List of cycles found in the server-side tree. - * - clientCycles (array of array of ids). List of cycles found in the client-side tree. - * - orphans (array of {id: string, parent: string}): List of nodes with - * either no parentid, or where the parent could not be found. - * - missingChildren (array of {parent: id, child: id}): - * List of parent/children where the child id couldn't be found - * - deletedChildren (array of { parent: id, child: id }): - * List of parent/children where child id was a deleted item (but still showed up - * in the children array) - * - multipleParents (array of {child: id, parents: array of ids}): - * List of children that were part of multiple parent arrays - * - deletedParents (array of ids) : List of records that aren't deleted but - * had deleted parents - * - childrenOnNonFolder (array of ids): list of non-folders that still have - * children arrays - * - duplicateChildren (array of ids): list of records who have the same - * child listed multiple times in their children array - * - parentNotFolder (array of ids): list of records that have parents that - * aren't folders - * - rootOnServer (boolean): true if the root came from the server - * - badClientRoots (array of ids): Contains any client-side root ids where - * the root is missing or isn't a (direct) child of the places root. - * - * - clientMissing: Array of ids on the server missing from the client - * - serverMissing: Array of ids on the client missing from the server - * - serverDeleted: Array of ids on the client that the server had marked as deleted. - * - serverUnexpected: Array of ids that appear on the server but shouldn't - * because the client attempts to never upload them. - * - differences: Array of {id: string, differences: string array} recording - * the non-structural properties that are differente between the client and server - * - structuralDifferences: As above, but contains the items where the differences were - * structural, that is, they contained childGUIDs or parentid - */ -class BookmarkProblemData { - constructor() { - this.rootOnServer = false; - this.missingIDs = 0; - - this.duplicates = []; - this.parentChildMismatches = []; - this.cycles = []; - this.clientCycles = []; - this.orphans = []; - this.missingChildren = []; - this.deletedChildren = []; - this.multipleParents = []; - this.deletedParents = []; - this.childrenOnNonFolder = []; - this.duplicateChildren = []; - this.parentNotFolder = []; - - this.badClientRoots = []; - this.clientMissing = []; - this.serverMissing = []; - this.serverDeleted = []; - this.serverUnexpected = []; - this.differences = []; - this.structuralDifferences = []; - } - - /** - * Convert ("difference", [{ differences: ["tags", "name"] }, { differences: ["name"] }]) into - * [{ name: "difference:tags", count: 1}, { name: "difference:name", count: 2 }], etc. - */ - _summarizeDifferences(prefix, diffs) { - let diffCounts = new Map(); - for (let { differences } of diffs) { - for (let type of differences) { - let name = prefix + ":" + type; - let count = diffCounts.get(name) || 0; - diffCounts.set(name, count + 1); - } - } - return [...diffCounts].map(([name, count]) => ({ name, count })); - } - - /** - * Produce a list summarizing problems found. Each entry contains {name, count}, - * where name is the field name for the problem, and count is the number of times - * the problem was encountered. - * - * Validation has failed if all counts are not 0. - * - * If the `full` argument is truthy, we also include information about which - * properties we saw structural differences in. Currently, this means either - * "sdiff:parentid" and "sdiff:childGUIDS" may be present. - */ - getSummary(full) { - let result = [ - { name: "clientMissing", count: this.clientMissing.length }, - { name: "serverMissing", count: this.serverMissing.length }, - { name: "serverDeleted", count: this.serverDeleted.length }, - { name: "serverUnexpected", count: this.serverUnexpected.length }, - - { name: "structuralDifferences", count: this.structuralDifferences.length }, - { name: "differences", count: this.differences.length }, - - { name: "missingIDs", count: this.missingIDs }, - { name: "rootOnServer", count: this.rootOnServer ? 1 : 0 }, - - { name: "duplicates", count: this.duplicates.length }, - { name: "parentChildMismatches", count: this.parentChildMismatches.length }, - { name: "cycles", count: this.cycles.length }, - { name: "clientCycles", count: this.clientCycles.length }, - { name: "badClientRoots", count: this.badClientRoots.length }, - { name: "orphans", count: this.orphans.length }, - { name: "missingChildren", count: this.missingChildren.length }, - { name: "deletedChildren", count: this.deletedChildren.length }, - { name: "multipleParents", count: this.multipleParents.length }, - { name: "deletedParents", count: this.deletedParents.length }, - { name: "childrenOnNonFolder", count: this.childrenOnNonFolder.length }, - { name: "duplicateChildren", count: this.duplicateChildren.length }, - { name: "parentNotFolder", count: this.parentNotFolder.length }, - ]; - if (full) { - let structural = this._summarizeDifferences("sdiff", this.structuralDifferences); - result.push.apply(result, structural); - } - return result; - } -} - -// Defined lazily to avoid initializing PlacesUtils.bookmarks too soon. -XPCOMUtils.defineLazyGetter(this, "SYNCED_ROOTS", () => [ - PlacesUtils.bookmarks.menuGuid, - PlacesUtils.bookmarks.toolbarGuid, - PlacesUtils.bookmarks.unfiledGuid, - PlacesUtils.bookmarks.mobileGuid, -]); - -class BookmarkValidator { - - _followQueries(recordMap) { - for (let [guid, entry] of recordMap) { - if (entry.type !== "query" && (!entry.bmkUri || !entry.bmkUri.startsWith("place:"))) { - continue; - } - // Might be worth trying to parse the place: query instead so that this - // works "automatically" with things like aboutsync. - let queryNodeParent = PlacesUtils.getFolderContents(entry, false, true); - if (!queryNodeParent || !queryNodeParent.root.hasChildren) { - continue; - } - queryNodeParent = queryNodeParent.root; - let queryNode = null; - let numSiblings = 0; - let containerWasOpen = queryNodeParent.containerOpen; - queryNodeParent.containerOpen = true; - try { - try { - numSiblings = queryNodeParent.childCount; - } catch (e) { - // This throws when we can't actually get the children. This is the - // case for history containers, tag queries, ... - continue; - } - for (let i = 0; i < numSiblings && !queryNode; ++i) { - let child = queryNodeParent.getChild(i); - if (child && child.bookmarkGuid && child.bookmarkGuid === guid) { - queryNode = child; - } - } - } finally { - queryNodeParent.containerOpen = containerWasOpen; - } - if (!queryNode) { - continue; - } - - let concreteId = PlacesUtils.getConcreteItemGuid(queryNode); - if (!concreteId) { - continue; - } - let concreteItem = recordMap.get(concreteId); - if (!concreteItem) { - continue; - } - entry.concrete = concreteItem; - } - } - - createClientRecordsFromTree(clientTree) { - // Iterate over the treeNode, converting it to something more similar to what - // the server stores. - let records = []; - let recordsByGuid = new Map(); - let syncedRoots = SYNCED_ROOTS; - function traverse(treeNode, synced) { - if (!synced) { - synced = syncedRoots.includes(treeNode.guid); - } else if (isNodeIgnored(treeNode)) { - synced = false; - } - let guid = PlacesSyncUtils.bookmarks.guidToSyncId(treeNode.guid); - let itemType = 'item'; - treeNode.ignored = !synced; - treeNode.id = guid; - switch (treeNode.type) { - case PlacesUtils.TYPE_X_MOZ_PLACE: - let query = null; - if (treeNode.annos && treeNode.uri.startsWith("place:")) { - query = treeNode.annos.find(({name}) => - name === PlacesSyncUtils.bookmarks.SMART_BOOKMARKS_ANNO); - } - if (query && query.value) { - itemType = 'query'; - } else { - itemType = 'bookmark'; - } - break; - case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: - let isLivemark = false; - if (treeNode.annos) { - for (let anno of treeNode.annos) { - if (anno.name === PlacesUtils.LMANNO_FEEDURI) { - isLivemark = true; - treeNode.feedUri = anno.value; - } else if (anno.name === PlacesUtils.LMANNO_SITEURI) { - isLivemark = true; - treeNode.siteUri = anno.value; - } - } - } - itemType = isLivemark ? "livemark" : "folder"; - break; - case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR: - itemType = 'separator'; - break; - } - - if (treeNode.tags) { - treeNode.tags = treeNode.tags.split(","); - } else { - treeNode.tags = []; - } - treeNode.type = itemType; - treeNode.pos = treeNode.index; - treeNode.bmkUri = treeNode.uri; - records.push(treeNode); - // We want to use the "real" guid here. - recordsByGuid.set(treeNode.guid, treeNode); - if (treeNode.type === 'folder') { - treeNode.childGUIDs = []; - if (!treeNode.children) { - treeNode.children = []; - } - for (let child of treeNode.children) { - traverse(child, synced); - child.parent = treeNode; - child.parentid = guid; - treeNode.childGUIDs.push(child.guid); - } - } - } - traverse(clientTree, false); - clientTree.id = 'places'; - this._followQueries(recordsByGuid); - return records; - } - - /** - * Process the server-side list. Mainly this builds the records into a tree, - * but it also records information about problems, and produces arrays of the - * deleted and non-deleted nodes. - * - * Returns an object containing: - * - records:Array of non-deleted records. Each record contains the following - * properties - * - childGUIDs (array of strings, only present if type is 'folder'): the - * list of child GUIDs stored on the server. - * - children (array of records, only present if type is 'folder'): - * each record has these same properties. This may differ in content - * from what you may expect from the childGUIDs list, as it won't - * contain any records that could not be found. - * - parent (record): The parent to this record. - * - Unchanged properties send down from the server: id, title, type, - * parentName, parentid, bmkURI, keyword, tags, pos, queryId, loadInSidebar - * - root: Root of the server-side bookmark tree. Has the same properties as - * above. - * - deletedRecords: As above, but only contains items that the server sent - * where it also sent indication that the item should be deleted. - * - problemData: a BookmarkProblemData object, with the caveat that - * the fields describing client/server relationship will not have been filled - * out yet. - */ - inspectServerRecords(serverRecords) { - let deletedItemIds = new Set(); - let idToRecord = new Map(); - let deletedRecords = []; - - let folders = []; - let problems = []; - - let problemData = new BookmarkProblemData(); - - let resultRecords = []; - - for (let record of serverRecords) { - if (!record.id) { - ++problemData.missingIDs; - continue; - } - if (record.deleted) { - deletedItemIds.add(record.id); - } else { - if (idToRecord.has(record.id)) { - problemData.duplicates.push(record.id); - continue; - } - } - idToRecord.set(record.id, record); - - if (record.children) { - if (record.type !== "folder") { - // Due to implementation details in engines/bookmarks.js, (Livemark - // subclassing BookmarkFolder) Livemarks will have a children array, - // but it should still be empty. - if (!record.children.length) { - continue; - } - // Otherwise we mark it as an error and still try to resolve the children - problemData.childrenOnNonFolder.push(record.id); - } - folders.push(record); - - if (new Set(record.children).size !== record.children.length) { - problemData.duplicateChildren.push(record.id) - } - - // The children array stores special guids as their local guid values, - // e.g. 'menu________' instead of 'menu', but all other parts of the - // serverside bookmark info stores it as the special value ('menu'). - record.childGUIDs = record.children; - record.children = record.children.map(childID => { - return PlacesSyncUtils.bookmarks.guidToSyncId(childID); - }); - } - } - - for (let deletedId of deletedItemIds) { - let record = idToRecord.get(deletedId); - if (record && !record.isDeleted) { - deletedRecords.push(record); - record.isDeleted = true; - } - } - - let root = idToRecord.get('places'); - - if (!root) { - // Fabricate a root. We want to remember that it's fake so that we can - // avoid complaining about stuff like it missing it's childGUIDs later. - root = { id: 'places', children: [], type: 'folder', title: '', fake: true }; - resultRecords.push(root); - idToRecord.set('places', root); - } else { - problemData.rootOnServer = true; - } - - // Build the tree, find orphans, and record most problems having to do with - // the tree structure. - for (let [id, record] of idToRecord) { - if (record === root) { - continue; - } - - if (record.isDeleted) { - continue; - } - - let parentID = record.parentid; - if (!parentID) { - problemData.orphans.push({id: record.id, parent: parentID}); - continue; - } - - let parent = idToRecord.get(parentID); - if (!parent) { - problemData.orphans.push({id: record.id, parent: parentID}); - continue; - } - - if (parent.type !== 'folder') { - problemData.parentNotFolder.push(record.id); - if (!parent.children) { - parent.children = []; - } - if (!parent.childGUIDs) { - parent.childGUIDs = []; - } - } - - if (!record.isDeleted) { - resultRecords.push(record); - } - - record.parent = parent; - if (parent !== root || problemData.rootOnServer) { - let childIndex = parent.children.indexOf(id); - if (childIndex < 0) { - problemData.parentChildMismatches.push({parent: parent.id, child: record.id}); - } else { - parent.children[childIndex] = record; - } - } else { - parent.children.push(record); - } - - if (parent.isDeleted && !record.isDeleted) { - problemData.deletedParents.push(record.id); - } - - // We used to check if the parentName on the server matches the actual - // local parent name, but given this is used only for de-duping a record - // the first time it is seen and expensive to keep up-to-date, we decided - // to just stop recording it. See bug 1276969 for more. - } - - // Check that we aren't missing any children. - for (let folder of folders) { - folder.unfilteredChildren = folder.children; - folder.children = []; - for (let ci = 0; ci < folder.unfilteredChildren.length; ++ci) { - let child = folder.unfilteredChildren[ci]; - let childObject; - if (typeof child == "string") { - // This can happen the parent refers to a child that has a different - // parentid, or if it refers to a missing or deleted child. It shouldn't - // be possible with totally valid bookmarks. - childObject = idToRecord.get(child); - if (!childObject) { - problemData.missingChildren.push({parent: folder.id, child}); - } else { - folder.unfilteredChildren[ci] = childObject; - if (childObject.isDeleted) { - problemData.deletedChildren.push({ parent: folder.id, child }); - } - } - } else { - childObject = child; - } - - if (!childObject) { - continue; - } - - if (childObject.parentid === folder.id) { - folder.children.push(childObject); - continue; - } - - // The child is very probably in multiple `children` arrays -- - // see if we already have a problem record about it. - let currentProblemRecord = problemData.multipleParents.find(pr => - pr.child === child); - - if (currentProblemRecord) { - currentProblemRecord.parents.push(folder.id); - continue; - } - - let otherParent = idToRecord.get(childObject.parentid); - // it's really an ... orphan ... sort of. - if (!otherParent) { - // if we never end up adding to this parent's list, we filter it out after this loop. - problemData.multipleParents.push({ - child, - parents: [folder.id] - }); - if (!problemData.orphans.some(r => r.id === child)) { - problemData.orphans.push({ - id: child, - parent: childObject.parentid - }); - } - continue; - } - - if (otherParent.isDeleted) { - if (!problemData.deletedParents.includes(child)) { - problemData.deletedParents.push(child); - } - continue; - } - - if (otherParent.childGUIDs && !otherParent.childGUIDs.includes(child)) { - if (!problemData.parentChildMismatches.some(r => r.child === child)) { - // Might not be possible to get here. - problemData.parentChildMismatches.push({ child, parent: folder.id }); - } - } - - problemData.multipleParents.push({ - child, - parents: [childObject.parentid, folder.id] - }); - } - } - problemData.multipleParents = problemData.multipleParents.filter(record => - record.parents.length >= 2); - - problemData.cycles = this._detectCycles(resultRecords); - - return { - deletedRecords, - records: resultRecords, - problemData, - root, - }; - } - - // helper for inspectServerRecords - _detectCycles(records) { - // currentPath and pathLookup contain the same data. pathLookup is faster to - // query, but currentPath gives is the order of traversal that we need in - // order to report the members of the cycles. - let pathLookup = new Set(); - let currentPath = []; - let cycles = []; - let seenEver = new Set(); - const traverse = node => { - if (pathLookup.has(node)) { - let cycleStart = currentPath.lastIndexOf(node); - let cyclePath = currentPath.slice(cycleStart).map(n => n.id); - cycles.push(cyclePath); - return; - } else if (seenEver.has(node)) { - // If we're checking the server, this is a problem, but it should already be reported. - // On the client, this could happen due to including `node.concrete` in the child list. - return; - } - seenEver.add(node); - let children = node.children || []; - if (node.concrete) { - children.push(node.concrete); - } - if (children) { - pathLookup.add(node); - currentPath.push(node); - for (let child of children) { - traverse(child); - } - currentPath.pop(); - pathLookup.delete(node); - } - }; - for (let record of records) { - if (!seenEver.has(record)) { - traverse(record); - } - } - - return cycles; - } - - // Perform client-side sanity checking that doesn't involve server data - _validateClient(problemData, clientRecords) { - problemData.clientCycles = this._detectCycles(clientRecords); - for (let rootGUID of SYNCED_ROOTS) { - let record = clientRecords.find(record => - record.guid === rootGUID); - if (!record || record.parentid !== "places") { - problemData.badClientRoots.push(rootGUID); - } - } - } - - /** - * Compare the list of server records with the client tree. - * - * Returns the same data as described in the inspectServerRecords comment, - * with the following additional fields. - * - clientRecords: an array of client records in a similar format to - * the .records (ie, server records) entry. - * - problemData is the same as for inspectServerRecords, except all properties - * will be filled out. - */ - compareServerWithClient(serverRecords, clientTree) { - - let clientRecords = this.createClientRecordsFromTree(clientTree); - let inspectionInfo = this.inspectServerRecords(serverRecords); - inspectionInfo.clientRecords = clientRecords; - - // Mainly do this to remove deleted items and normalize child guids. - serverRecords = inspectionInfo.records; - let problemData = inspectionInfo.problemData; - - this._validateClient(problemData, clientRecords); - - let matches = []; - - let allRecords = new Map(); - let serverDeletedLookup = new Set(inspectionInfo.deletedRecords.map(r => r.id)); - - for (let sr of serverRecords) { - if (sr.fake) { - continue; - } - allRecords.set(sr.id, {client: null, server: sr}); - } - - for (let cr of clientRecords) { - let unified = allRecords.get(cr.id); - if (!unified) { - allRecords.set(cr.id, {client: cr, server: null}); - } else { - unified.client = cr; - } - } - - - for (let [id, {client, server}] of allRecords) { - if (!client && server) { - problemData.clientMissing.push(id); - continue; - } - if (!server && client) { - if (serverDeletedLookup.has(id)) { - problemData.serverDeleted.push(id); - } else if (!client.ignored && client.id != "places") { - problemData.serverMissing.push(id); - } - continue; - } - if (server && client && client.ignored) { - problemData.serverUnexpected.push(id); - } - let differences = []; - let structuralDifferences = []; - - // Don't bother comparing titles of roots. It's okay if locally it's - // "Mobile Bookmarks", but the server thinks it's "mobile". - // TODO: We probably should be handing other localized bookmarks (e.g. - // default bookmarks) here as well, see bug 1316041. - if (!SYNCED_ROOTS.includes(client.guid)) { - // We want to treat undefined, null and an empty string as identical - if ((client.title || "") !== (server.title || "")) { - differences.push("title"); - } - } - - if (client.parentid || server.parentid) { - if (client.parentid !== server.parentid) { - structuralDifferences.push('parentid'); - } - } - - if (client.tags || server.tags) { - let cl = client.tags || []; - let sl = server.tags || []; - if (cl.length !== sl.length || !cl.every((tag, i) => sl.indexOf(tag) >= 0)) { - differences.push('tags'); - } - } - - let sameType = client.type === server.type; - if (!sameType) { - if (server.type === "query" && client.type === "bookmark" && client.bmkUri.startsWith("place:")) { - sameType = true; - } - } - - - if (!sameType) { - differences.push('type'); - } else { - switch (server.type) { - case 'bookmark': - case 'query': - if (server.bmkUri !== client.bmkUri) { - differences.push('bmkUri'); - } - break; - case "livemark": - if (server.feedUri != client.feedUri) { - differences.push("feedUri"); - } - if (server.siteUri != client.siteUri) { - differences.push("siteUri"); - } - break; - case 'folder': - if (server.id === 'places' && !problemData.rootOnServer) { - // It's the fabricated places root. It won't have the GUIDs, but - // it doesn't matter. - break; - } - if (client.childGUIDs || server.childGUIDs) { - let cl = client.childGUIDs || []; - let sl = server.childGUIDs || []; - if (cl.length !== sl.length || !cl.every((id, i) => sl[i] === id)) { - structuralDifferences.push('childGUIDs'); - } - } - break; - } - } - - if (differences.length) { - problemData.differences.push({id, differences}); - } - if (structuralDifferences.length) { - problemData.structuralDifferences.push({ id, differences: structuralDifferences }); - } - } - return inspectionInfo; - } - - _getServerState(engine) { - let collection = engine.itemSource(); - let collectionKey = engine.service.collectionKeys.keyForCollection(engine.name); - collection.full = true; - let items = []; - collection.recordHandler = function(item) { - item.decrypt(collectionKey); - items.push(item.cleartext); - }; - let resp = collection.getBatched(); - if (!resp.success) { - throw resp; - } - return items; - } - - validate(engine) { - let self = this; - return Task.spawn(function*() { - let start = Date.now(); - let clientTree = yield PlacesUtils.promiseBookmarksTree("", { - includeItemIds: true - }); - let serverState = self._getServerState(engine); - let serverRecordCount = serverState.length; - let result = self.compareServerWithClient(serverState, clientTree); - let end = Date.now(); - let duration = end-start; - return { - duration, - version: self.version, - problems: result.problemData, - recordCount: serverRecordCount - }; - }); - } - -}; - -BookmarkValidator.prototype.version = BOOKMARK_VALIDATOR_VERSION; - diff --git a/services/sync/modules/browserid_identity.js b/services/sync/modules/browserid_identity.js deleted file mode 100644 index db3821518..000000000 --- a/services/sync/modules/browserid_identity.js +++ /dev/null @@ -1,869 +0,0 @@ -/* 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/. */ - -"use strict"; - -this.EXPORTED_SYMBOLS = ["BrowserIDManager", "AuthenticationError"]; - -var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; - -Cu.import("resource://gre/modules/Log.jsm"); -Cu.import("resource://services-common/async.js"); -Cu.import("resource://services-common/utils.js"); -Cu.import("resource://services-common/tokenserverclient.js"); -Cu.import("resource://services-crypto/utils.js"); -Cu.import("resource://services-sync/identity.js"); -Cu.import("resource://services-sync/util.js"); -Cu.import("resource://services-common/tokenserverclient.js"); -Cu.import("resource://gre/modules/Services.jsm"); -Cu.import("resource://services-sync/constants.js"); -Cu.import("resource://gre/modules/Promise.jsm"); -Cu.import("resource://services-sync/stages/cluster.js"); -Cu.import("resource://gre/modules/FxAccounts.jsm"); - -// Lazy imports to prevent unnecessary load on startup. -XPCOMUtils.defineLazyModuleGetter(this, "Weave", - "resource://services-sync/main.js"); - -XPCOMUtils.defineLazyModuleGetter(this, "BulkKeyBundle", - "resource://services-sync/keys.js"); - -XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts", - "resource://gre/modules/FxAccounts.jsm"); - -XPCOMUtils.defineLazyGetter(this, 'log', function() { - let log = Log.repository.getLogger("Sync.BrowserIDManager"); - log.level = Log.Level[Svc.Prefs.get("log.logger.identity")] || Log.Level.Error; - return log; -}); - -// FxAccountsCommon.js doesn't use a "namespace", so create one here. -var fxAccountsCommon = {}; -Cu.import("resource://gre/modules/FxAccountsCommon.js", fxAccountsCommon); - -const OBSERVER_TOPICS = [ - fxAccountsCommon.ONLOGIN_NOTIFICATION, - fxAccountsCommon.ONLOGOUT_NOTIFICATION, - fxAccountsCommon.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION, -]; - -const PREF_SYNC_SHOW_CUSTOMIZATION = "services.sync-setup.ui.showCustomizationDialog"; - -function deriveKeyBundle(kB) { - let out = CryptoUtils.hkdf(kB, undefined, - "identity.mozilla.com/picl/v1/oldsync", 2*32); - let bundle = new BulkKeyBundle(); - // [encryptionKey, hmacKey] - bundle.keyPair = [out.slice(0, 32), out.slice(32, 64)]; - return bundle; -} - -/* - General authentication error for abstracting authentication - errors from multiple sources (e.g., from FxAccounts, TokenServer). - details is additional details about the error - it might be a string, or - some other error object (which should do the right thing when toString() is - called on it) -*/ -function AuthenticationError(details, source) { - this.details = details; - this.source = source; -} - -AuthenticationError.prototype = { - toString: function() { - return "AuthenticationError(" + this.details + ")"; - } -} - -this.BrowserIDManager = function BrowserIDManager() { - // NOTE: _fxaService and _tokenServerClient are replaced with mocks by - // the test suite. - this._fxaService = fxAccounts; - this._tokenServerClient = new TokenServerClient(); - this._tokenServerClient.observerPrefix = "weave:service"; - // will be a promise that resolves when we are ready to authenticate - this.whenReadyToAuthenticate = null; - this._log = log; -}; - -this.BrowserIDManager.prototype = { - __proto__: IdentityManager.prototype, - - _fxaService: null, - _tokenServerClient: null, - // https://docs.services.mozilla.com/token/apis.html - _token: null, - _signedInUser: null, // the signedinuser we got from FxAccounts. - - // null if no error, otherwise a LOGIN_FAILED_* value that indicates why - // we failed to authenticate (but note it might not be an actual - // authentication problem, just a transient network error or similar) - _authFailureReason: null, - - // it takes some time to fetch a sync key bundle, so until this flag is set, - // we don't consider the lack of a keybundle as a failure state. - _shouldHaveSyncKeyBundle: false, - - get needsCustomization() { - try { - return Services.prefs.getBoolPref(PREF_SYNC_SHOW_CUSTOMIZATION); - } catch (e) { - return false; - } - }, - - hashedUID() { - if (!this._token) { - throw new Error("hashedUID: Don't have token"); - } - return this._token.hashed_fxa_uid - }, - - deviceID() { - return this._signedInUser && this._signedInUser.deviceId; - }, - - initialize: function() { - for (let topic of OBSERVER_TOPICS) { - Services.obs.addObserver(this, topic, false); - } - // and a background fetch of account data just so we can set this.account, - // so we have a username available before we've actually done a login. - // XXX - this is actually a hack just for tests and really shouldn't be - // necessary. Also, you'd think it would be safe to allow this.account to - // be set to null when there's no user logged in, but argue with the test - // suite, not with me :) - this._fxaService.getSignedInUser().then(accountData => { - if (accountData) { - this.account = accountData.email; - } - }).catch(err => { - // As above, this is only for tests so it is safe to ignore. - }); - }, - - /** - * Ensure the user is logged in. Returns a promise that resolves when - * the user is logged in, or is rejected if the login attempt has failed. - */ - ensureLoggedIn: function() { - if (!this._shouldHaveSyncKeyBundle && this.whenReadyToAuthenticate) { - // We are already in the process of logging in. - return this.whenReadyToAuthenticate.promise; - } - - // If we are already happy then there is nothing more to do. - if (this._syncKeyBundle) { - return Promise.resolve(); - } - - // Similarly, if we have a previous failure that implies an explicit - // re-entering of credentials by the user is necessary we don't take any - // further action - an observer will fire when the user does that. - if (Weave.Status.login == LOGIN_FAILED_LOGIN_REJECTED) { - return Promise.reject(new Error("User needs to re-authenticate")); - } - - // So - we've a previous auth problem and aren't currently attempting to - // log in - so fire that off. - this.initializeWithCurrentIdentity(); - return this.whenReadyToAuthenticate.promise; - }, - - finalize: function() { - // After this is called, we can expect Service.identity != this. - for (let topic of OBSERVER_TOPICS) { - Services.obs.removeObserver(this, topic); - } - this.resetCredentials(); - this._signedInUser = null; - }, - - offerSyncOptions: function () { - // If the user chose to "Customize sync options" when signing - // up with Firefox Accounts, ask them to choose what to sync. - const url = "chrome://browser/content/sync/customize.xul"; - const features = "centerscreen,chrome,modal,dialog,resizable=no"; - let win = Services.wm.getMostRecentWindow("navigator:browser"); - - let data = {accepted: false}; - win.openDialog(url, "_blank", features, data); - - return data; - }, - - initializeWithCurrentIdentity: function(isInitialSync=false) { - // While this function returns a promise that resolves once we've started - // the auth process, that process is complete when - // this.whenReadyToAuthenticate.promise resolves. - this._log.trace("initializeWithCurrentIdentity"); - - // Reset the world before we do anything async. - this.whenReadyToAuthenticate = Promise.defer(); - this.whenReadyToAuthenticate.promise.catch(err => { - this._log.error("Could not authenticate", err); - }); - - // initializeWithCurrentIdentity() can be called after the - // identity module was first initialized, e.g., after the - // user completes a force authentication, so we should make - // sure all credentials are reset before proceeding. - this.resetCredentials(); - this._authFailureReason = null; - - return this._fxaService.getSignedInUser().then(accountData => { - if (!accountData) { - this._log.info("initializeWithCurrentIdentity has no user logged in"); - this.account = null; - // and we are as ready as we can ever be for auth. - this._shouldHaveSyncKeyBundle = true; - this.whenReadyToAuthenticate.reject("no user is logged in"); - return; - } - - this.account = accountData.email; - this._updateSignedInUser(accountData); - // The user must be verified before we can do anything at all; we kick - // this and the rest of initialization off in the background (ie, we - // don't return the promise) - this._log.info("Waiting for user to be verified."); - this._fxaService.whenVerified(accountData).then(accountData => { - this._updateSignedInUser(accountData); - this._log.info("Starting fetch for key bundle."); - if (this.needsCustomization) { - let data = this.offerSyncOptions(); - if (data.accepted) { - Services.prefs.clearUserPref(PREF_SYNC_SHOW_CUSTOMIZATION); - - // Mark any non-selected engines as declined. - Weave.Service.engineManager.declineDisabled(); - } else { - // Log out if the user canceled the dialog. - return this._fxaService.signOut(); - } - } - }).then(() => { - return this._fetchTokenForUser(); - }).then(token => { - this._token = token; - this._shouldHaveSyncKeyBundle = true; // and we should actually have one... - this.whenReadyToAuthenticate.resolve(); - this._log.info("Background fetch for key bundle done"); - Weave.Status.login = LOGIN_SUCCEEDED; - if (isInitialSync) { - this._log.info("Doing initial sync actions"); - Svc.Prefs.set("firstSync", "resetClient"); - Services.obs.notifyObservers(null, "weave:service:setup-complete", null); - Weave.Utils.nextTick(Weave.Service.sync, Weave.Service); - } - }).catch(authErr => { - // report what failed... - this._log.error("Background fetch for key bundle failed", authErr); - this._shouldHaveSyncKeyBundle = true; // but we probably don't have one... - this.whenReadyToAuthenticate.reject(authErr); - }); - // and we are done - the fetch continues on in the background... - }).catch(err => { - this._log.error("Processing logged in account", err); - }); - }, - - _updateSignedInUser: function(userData) { - // This object should only ever be used for a single user. It is an - // error to update the data if the user changes (but updates are still - // necessary, as each call may add more attributes to the user). - // We start with no user, so an initial update is always ok. - if (this._signedInUser && this._signedInUser.email != userData.email) { - throw new Error("Attempting to update to a different user.") - } - this._signedInUser = userData; - }, - - logout: function() { - // This will be called when sync fails (or when the account is being - // unlinked etc). It may have failed because we got a 401 from a sync - // server, so we nuke the token. Next time sync runs and wants an - // authentication header, we will notice the lack of the token and fetch a - // new one. - this._token = null; - }, - - observe: function (subject, topic, data) { - this._log.debug("observed " + topic); - switch (topic) { - case fxAccountsCommon.ONLOGIN_NOTIFICATION: - // This should only happen if we've been initialized without a current - // user - otherwise we'd have seen the LOGOUT notification and been - // thrown away. - // The exception is when we've initialized with a user that needs to - // reauth with the server - in that case we will also get here, but - // should have the same identity. - // initializeWithCurrentIdentity will throw and log if these constraints - // aren't met (indirectly, via _updateSignedInUser()), so just go ahead - // and do the init. - this.initializeWithCurrentIdentity(true); - break; - - case fxAccountsCommon.ONLOGOUT_NOTIFICATION: - Weave.Service.startOver(); - // startOver will cause this instance to be thrown away, so there's - // nothing else to do. - break; - - case fxAccountsCommon.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION: - // throw away token and fetch a new one - this.resetCredentials(); - this._ensureValidToken().catch(err => - this._log.error("Error while fetching a new token", err)); - break; - } - }, - - /** - * Compute the sha256 of the message bytes. Return bytes. - */ - _sha256: function(message) { - let hasher = Cc["@mozilla.org/security/hash;1"] - .createInstance(Ci.nsICryptoHash); - hasher.init(hasher.SHA256); - return CryptoUtils.digestBytes(message, hasher); - }, - - /** - * Compute the X-Client-State header given the byte string kB. - * - * Return string: hex(first16Bytes(sha256(kBbytes))) - */ - _computeXClientState: function(kBbytes) { - return CommonUtils.bytesAsHex(this._sha256(kBbytes).slice(0, 16), false); - }, - - /** - * Provide override point for testing token expiration. - */ - _now: function() { - return this._fxaService.now() - }, - - get _localtimeOffsetMsec() { - return this._fxaService.localtimeOffsetMsec; - }, - - usernameFromAccount: function(val) { - // we don't differentiate between "username" and "account" - return val; - }, - - /** - * Obtains the HTTP Basic auth password. - * - * Returns a string if set or null if it is not set. - */ - get basicPassword() { - this._log.error("basicPassword getter should be not used in BrowserIDManager"); - return null; - }, - - /** - * Set the HTTP basic password to use. - * - * Changes will not persist unless persistSyncCredentials() is called. - */ - set basicPassword(value) { - throw "basicPassword setter should be not used in BrowserIDManager"; - }, - - /** - * Obtain the Sync Key. - * - * This returns a 26 character "friendly" Base32 encoded string on success or - * null if no Sync Key could be found. - * - * If the Sync Key hasn't been set in this session, this will look in the - * password manager for the sync key. - */ - get syncKey() { - if (this.syncKeyBundle) { - // TODO: This is probably fine because the code shouldn't be - // using the sync key directly (it should use the sync key - // bundle), but I don't like it. We should probably refactor - // code that is inspecting this to not do validation on this - // field directly and instead call a isSyncKeyValid() function - // that we can override. - return "99999999999999999999999999"; - } - else { - return null; - } - }, - - set syncKey(value) { - throw "syncKey setter should be not used in BrowserIDManager"; - }, - - get syncKeyBundle() { - return this._syncKeyBundle; - }, - - /** - * Resets/Drops all credentials we hold for the current user. - */ - resetCredentials: function() { - this.resetSyncKey(); - this._token = null; - // The cluster URL comes from the token, so resetting it to empty will - // force Sync to not accidentally use a value from an earlier token. - Weave.Service.clusterURL = null; - }, - - /** - * Resets/Drops the sync key we hold for the current user. - */ - resetSyncKey: function() { - this._syncKey = null; - this._syncKeyBundle = null; - this._syncKeyUpdated = true; - this._shouldHaveSyncKeyBundle = false; - }, - - /** - * Pre-fetches any information that might help with migration away from this - * identity. Called after every sync and is really just an optimization that - * allows us to avoid a network request for when we actually need the - * migration info. - */ - prefetchMigrationSentinel: function(service) { - // nothing to do here until we decide to migrate away from FxA. - }, - - /** - * Return credentials hosts for this identity only. - */ - _getSyncCredentialsHosts: function() { - return Utils.getSyncCredentialsHostsFxA(); - }, - - /** - * The current state of the auth credentials. - * - * This essentially validates that enough credentials are available to use - * Sync. It doesn't check we have all the keys we need as the master-password - * may have been locked when we tried to get them - we rely on - * unlockAndVerifyAuthState to check that for us. - */ - get currentAuthState() { - if (this._authFailureReason) { - this._log.info("currentAuthState returning " + this._authFailureReason + - " due to previous failure"); - return this._authFailureReason; - } - // TODO: need to revisit this. Currently this isn't ready to go until - // both the username and syncKeyBundle are both configured and having no - // username seems to make things fail fast so that's good. - if (!this.username) { - return LOGIN_FAILED_NO_USERNAME; - } - - return STATUS_OK; - }, - - // Do we currently have keys, or do we have enough that we should be able - // to successfully fetch them? - _canFetchKeys: function() { - let userData = this._signedInUser; - // a keyFetchToken means we can almost certainly grab them. - // kA and kB means we already have them. - return userData && (userData.keyFetchToken || (userData.kA && userData.kB)); - }, - - /** - * Verify the current auth state, unlocking the master-password if necessary. - * - * Returns a promise that resolves with the current auth state after - * attempting to unlock. - */ - unlockAndVerifyAuthState: function() { - if (this._canFetchKeys()) { - log.debug("unlockAndVerifyAuthState already has (or can fetch) sync keys"); - return Promise.resolve(STATUS_OK); - } - // so no keys - ensure MP unlocked. - if (!Utils.ensureMPUnlocked()) { - // user declined to unlock, so we don't know if they are stored there. - log.debug("unlockAndVerifyAuthState: user declined to unlock master-password"); - return Promise.resolve(MASTER_PASSWORD_LOCKED); - } - // now we are unlocked we must re-fetch the user data as we may now have - // the details that were previously locked away. - return this._fxaService.getSignedInUser().then( - accountData => { - this._updateSignedInUser(accountData); - // If we still can't get keys it probably means the user authenticated - // without unlocking the MP or cleared the saved logins, so we've now - // lost them - the user will need to reauth before continuing. - let result; - if (this._canFetchKeys()) { - result = STATUS_OK; - } else { - result = LOGIN_FAILED_LOGIN_REJECTED; - } - log.debug("unlockAndVerifyAuthState re-fetched credentials and is returning", result); - return result; - } - ); - }, - - /** - * Do we have a non-null, not yet expired token for the user currently - * signed in? - */ - hasValidToken: function() { - // If pref is set to ignore cached authentication credentials for debugging, - // then return false to force the fetching of a new token. - let ignoreCachedAuthCredentials = false; - try { - ignoreCachedAuthCredentials = Svc.Prefs.get("debug.ignoreCachedAuthCredentials"); - } catch(e) { - // Pref doesn't exist - } - if (ignoreCachedAuthCredentials) { - return false; - } - if (!this._token) { - return false; - } - if (this._token.expiration < this._now()) { - return false; - } - return true; - }, - - // Get our tokenServerURL - a private helper. Returns a string. - get _tokenServerUrl() { - // We used to support services.sync.tokenServerURI but this was a - // pain-point for people using non-default servers as Sync may auto-reset - // all services.sync prefs. So if that still exists, it wins. - let url = Svc.Prefs.get("tokenServerURI"); // Svc.Prefs "root" is services.sync - if (!url) { - url = Services.prefs.getCharPref("identity.sync.tokenserver.uri"); - } - while (url.endsWith("/")) { // trailing slashes cause problems... - url = url.slice(0, -1); - } - return url; - }, - - // Refresh the sync token for our user. Returns a promise that resolves - // with a token (which may be null in one sad edge-case), or rejects with an - // error. - _fetchTokenForUser: function() { - // tokenServerURI is mis-named - convention is uri means nsISomething... - let tokenServerURI = this._tokenServerUrl; - let log = this._log; - let client = this._tokenServerClient; - let fxa = this._fxaService; - let userData = this._signedInUser; - - // We need kA and kB for things to work. If we don't have them, just - // return null for the token - sync calling unlockAndVerifyAuthState() - // before actually syncing will setup the error states if necessary. - if (!this._canFetchKeys()) { - log.info("Unable to fetch keys (master-password locked?), so aborting token fetch"); - return Promise.resolve(null); - } - - let maybeFetchKeys = () => { - // This is called at login time and every time we need a new token - in - // the latter case we already have kA and kB, so optimise that case. - if (userData.kA && userData.kB) { - return; - } - log.info("Fetching new keys"); - return this._fxaService.getKeys().then( - newUserData => { - userData = newUserData; - this._updateSignedInUser(userData); // throws if the user changed. - } - ); - } - - let getToken = assertion => { - log.debug("Getting a token"); - let deferred = Promise.defer(); - let cb = function (err, token) { - if (err) { - return deferred.reject(err); - } - log.debug("Successfully got a sync token"); - return deferred.resolve(token); - }; - - let kBbytes = CommonUtils.hexToBytes(userData.kB); - let headers = {"X-Client-State": this._computeXClientState(kBbytes)}; - client.getTokenFromBrowserIDAssertion(tokenServerURI, assertion, cb, headers); - return deferred.promise; - } - - let getAssertion = () => { - log.info("Getting an assertion from", tokenServerURI); - let audience = Services.io.newURI(tokenServerURI, null, null).prePath; - return fxa.getAssertion(audience); - }; - - // wait until the account email is verified and we know that - // getAssertion() will return a real assertion (not null). - return fxa.whenVerified(this._signedInUser) - .then(() => maybeFetchKeys()) - .then(() => getAssertion()) - .then(assertion => getToken(assertion)) - .catch(err => { - // If we get a 401 fetching the token it may be that our certificate - // needs to be regenerated. - if (!err.response || err.response.status !== 401) { - return Promise.reject(err); - } - log.warn("Token server returned 401, refreshing certificate and retrying token fetch"); - return fxa.invalidateCertificate() - .then(() => getAssertion()) - .then(assertion => getToken(assertion)) - }) - .then(token => { - // TODO: Make it be only 80% of the duration, so refresh the token - // before it actually expires. This is to avoid sync storage errors - // otherwise, we get a nasty notification bar briefly. Bug 966568. - token.expiration = this._now() + (token.duration * 1000) * 0.80; - if (!this._syncKeyBundle) { - // We are given kA/kB as hex. - this._syncKeyBundle = deriveKeyBundle(Utils.hexToBytes(userData.kB)); - } - return token; - }) - .catch(err => { - // TODO: unify these errors - we need to handle errors thrown by - // both tokenserverclient and hawkclient. - // A tokenserver error thrown based on a bad response. - if (err.response && err.response.status === 401) { - err = new AuthenticationError(err, "tokenserver"); - // A hawkclient error. - } else if (err.code && err.code === 401) { - err = new AuthenticationError(err, "hawkclient"); - // An FxAccounts.jsm error. - } else if (err.message == fxAccountsCommon.ERROR_AUTH_ERROR) { - err = new AuthenticationError(err, "fxaccounts"); - } - - // TODO: write tests to make sure that different auth error cases are handled here - // properly: auth error getting assertion, auth error getting token (invalid generation - // and client-state error) - if (err instanceof AuthenticationError) { - this._log.error("Authentication error in _fetchTokenForUser", err); - // set it to the "fatal" LOGIN_FAILED_LOGIN_REJECTED reason. - this._authFailureReason = LOGIN_FAILED_LOGIN_REJECTED; - } else { - this._log.error("Non-authentication error in _fetchTokenForUser", err); - // for now assume it is just a transient network related problem - // (although sadly, it might also be a regular unhandled exception) - this._authFailureReason = LOGIN_FAILED_NETWORK_ERROR; - } - // this._authFailureReason being set to be non-null in the above if clause - // ensures we are in the correct currentAuthState, and - // this._shouldHaveSyncKeyBundle being true ensures everything that cares knows - // that there is no authentication dance still under way. - this._shouldHaveSyncKeyBundle = true; - Weave.Status.login = this._authFailureReason; - throw err; - }); - }, - - // Returns a promise that is resolved when we have a valid token for the - // current user stored in this._token. When resolved, this._token is valid. - _ensureValidToken: function() { - if (this.hasValidToken()) { - this._log.debug("_ensureValidToken already has one"); - return Promise.resolve(); - } - const notifyStateChanged = - () => Services.obs.notifyObservers(null, "weave:service:login:change", null); - // reset this._token as a safety net to reduce the possibility of us - // repeatedly attempting to use an invalid token if _fetchTokenForUser throws. - this._token = null; - return this._fetchTokenForUser().then( - token => { - this._token = token; - notifyStateChanged(); - }, - error => { - notifyStateChanged(); - throw error - } - ); - }, - - getResourceAuthenticator: function () { - return this._getAuthenticationHeader.bind(this); - }, - - /** - * Obtain a function to be used for adding auth to RESTRequest instances. - */ - getRESTRequestAuthenticator: function() { - return this._addAuthenticationHeader.bind(this); - }, - - /** - * @return a Hawk HTTP Authorization Header, lightly wrapped, for the .uri - * of a RESTRequest or AsyncResponse object. - */ - _getAuthenticationHeader: function(httpObject, method) { - let cb = Async.makeSpinningCallback(); - this._ensureValidToken().then(cb, cb); - // Note that in failure states we return null, causing the request to be - // made without authorization headers, thereby presumably causing a 401, - // which causes Sync to log out. If we throw, this may not happen as - // expected. - try { - cb.wait(); - } catch (ex) { - if (Async.isShutdownException(ex)) { - throw ex; - } - this._log.error("Failed to fetch a token for authentication", ex); - return null; - } - if (!this._token) { - return null; - } - let credentials = {algorithm: "sha256", - id: this._token.id, - key: this._token.key, - }; - method = method || httpObject.method; - - // Get the local clock offset from the Firefox Accounts server. This should - // be close to the offset from the storage server. - let options = { - now: this._now(), - localtimeOffsetMsec: this._localtimeOffsetMsec, - credentials: credentials, - }; - - let headerValue = CryptoUtils.computeHAWK(httpObject.uri, method, options); - return {headers: {authorization: headerValue.field}}; - }, - - _addAuthenticationHeader: function(request, method) { - let header = this._getAuthenticationHeader(request, method); - if (!header) { - return null; - } - request.setHeader("authorization", header.headers.authorization); - return request; - }, - - createClusterManager: function(service) { - return new BrowserIDClusterManager(service); - }, - - // Tell Sync what the login status should be if it saw a 401 fetching - // info/collections as part of login verification (typically immediately - // after login.) - // In our case, it almost certainly means a transient error fetching a token - // (and hitting this will cause us to logout, which will correctly handle an - // authoritative login issue.) - loginStatusFromVerification404() { - return LOGIN_FAILED_NETWORK_ERROR; - }, -}; - -/* An implementation of the ClusterManager for this identity - */ - -function BrowserIDClusterManager(service) { - ClusterManager.call(this, service); -} - -BrowserIDClusterManager.prototype = { - __proto__: ClusterManager.prototype, - - _findCluster: function() { - let endPointFromIdentityToken = function() { - // The only reason (in theory ;) that we can end up with a null token - // is when this.identity._canFetchKeys() returned false. In turn, this - // should only happen if the master-password is locked or the credentials - // storage is screwed, and in those cases we shouldn't have started - // syncing so shouldn't get here anyway. - // But better safe than sorry! To keep things clearer, throw an explicit - // exception - the message will appear in the logs and the error will be - // treated as transient. - if (!this.identity._token) { - throw new Error("Can't get a cluster URL as we can't fetch keys."); - } - let endpoint = this.identity._token.endpoint; - // For Sync 1.5 storage endpoints, we use the base endpoint verbatim. - // However, it should end in "/" because we will extend it with - // well known path components. So we add a "/" if it's missing. - if (!endpoint.endsWith("/")) { - endpoint += "/"; - } - log.debug("_findCluster returning " + endpoint); - return endpoint; - }.bind(this); - - // Spinningly ensure we are ready to authenticate and have a valid token. - let promiseClusterURL = function() { - return this.identity.whenReadyToAuthenticate.promise.then( - () => { - // We need to handle node reassignment here. If we are being asked - // for a clusterURL while the service already has a clusterURL, then - // it's likely a 401 was received using the existing token - in which - // case we just discard the existing token and fetch a new one. - if (this.service.clusterURL) { - log.debug("_findCluster has a pre-existing clusterURL, so discarding the current token"); - this.identity._token = null; - } - return this.identity._ensureValidToken(); - } - ).then(endPointFromIdentityToken - ); - }.bind(this); - - let cb = Async.makeSpinningCallback(); - promiseClusterURL().then(function (clusterURL) { - cb(null, clusterURL); - }).then( - null, err => { - log.info("Failed to fetch the cluster URL", err); - // service.js's verifyLogin() method will attempt to fetch a cluster - // URL when it sees a 401. If it gets null, it treats it as a "real" - // auth error and sets Status.login to LOGIN_FAILED_LOGIN_REJECTED, which - // in turn causes a notification bar to appear informing the user they - // need to re-authenticate. - // On the other hand, if fetching the cluster URL fails with an exception, - // verifyLogin() assumes it is a transient error, and thus doesn't show - // the notification bar under the assumption the issue will resolve - // itself. - // Thus: - // * On a real 401, we must return null. - // * On any other problem we must let an exception bubble up. - if (err instanceof AuthenticationError) { - // callback with no error and a null result - cb.wait() returns null. - cb(null, null); - } else { - // callback with an error - cb.wait() completes by raising an exception. - cb(err); - } - }); - return cb.wait(); - }, - - getUserBaseURL: function() { - // Legacy Sync and FxA Sync construct the userBaseURL differently. Legacy - // Sync appends path components onto an empty path, and in FxA Sync the - // token server constructs this for us in an opaque manner. Since the - // cluster manager already sets the clusterURL on Service and also has - // access to the current identity, we added this functionality here. - return this.service.clusterURL; - } -} diff --git a/services/sync/modules/collection_validator.js b/services/sync/modules/collection_validator.js deleted file mode 100644 index 41141bba3..000000000 --- a/services/sync/modules/collection_validator.js +++ /dev/null @@ -1,204 +0,0 @@ -/* 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/. */ - -"use strict"; - -const Cu = Components.utils; - -Cu.import("resource://services-sync/record.js"); -Cu.import("resource://services-sync/main.js"); - -this.EXPORTED_SYMBOLS = ["CollectionValidator", "CollectionProblemData"]; - -class CollectionProblemData { - constructor() { - this.missingIDs = 0; - this.duplicates = []; - this.clientMissing = []; - this.serverMissing = []; - this.serverDeleted = []; - this.serverUnexpected = []; - this.differences = []; - } - - /** - * Produce a list summarizing problems found. Each entry contains {name, count}, - * where name is the field name for the problem, and count is the number of times - * the problem was encountered. - * - * Validation has failed if all counts are not 0. - */ - getSummary() { - return [ - { name: "clientMissing", count: this.clientMissing.length }, - { name: "serverMissing", count: this.serverMissing.length }, - { name: "serverDeleted", count: this.serverDeleted.length }, - { name: "serverUnexpected", count: this.serverUnexpected.length }, - { name: "differences", count: this.differences.length }, - { name: "missingIDs", count: this.missingIDs }, - { name: "duplicates", count: this.duplicates.length } - ]; - } -} - -class CollectionValidator { - // Construct a generic collection validator. This is intended to be called by - // subclasses. - // - name: Name of the engine - // - idProp: Property that identifies a record. That is, if a client and server - // record have the same value for the idProp property, they should be - // compared against eachother. - // - props: Array of properties that should be compared - constructor(name, idProp, props) { - this.name = name; - this.props = props; - this.idProp = idProp; - } - - // Should a custom ProblemData type be needed, return it here. - emptyProblemData() { - return new CollectionProblemData(); - } - - getServerItems(engine) { - let collection = engine.itemSource(); - let collectionKey = engine.service.collectionKeys.keyForCollection(engine.name); - collection.full = true; - let items = []; - collection.recordHandler = function(item) { - item.decrypt(collectionKey); - items.push(item.cleartext); - }; - let resp = collection.getBatched(); - if (!resp.success) { - throw resp; - } - return items; - } - - // Should return a promise that resolves to an array of client items. - getClientItems() { - return Promise.reject("Must implement"); - } - - // Turn the client item into something that can be compared with the server item, - // and is also safe to mutate. - normalizeClientItem(item) { - return Cu.cloneInto(item, {}); - } - - // Turn the server item into something that can be easily compared with the client - // items. - normalizeServerItem(item) { - return item; - } - - // Return whether or not a server item should be present on the client. Expected - // to be overridden. - clientUnderstands(item) { - return true; - } - - // Return whether or not a client item should be present on the server. Expected - // to be overridden - syncedByClient(item) { - return true; - } - - // Compare the server item and the client item, and return a list of property - // names that are different. Can be overridden if needed. - getDifferences(client, server) { - let differences = []; - for (let prop of this.props) { - let clientProp = client[prop]; - let serverProp = server[prop]; - if ((clientProp || "") !== (serverProp || "")) { - differences.push(prop); - } - } - return differences; - } - - // Returns an object containing - // problemData: an instance of the class returned by emptyProblemData(), - // clientRecords: Normalized client records - // records: Normalized server records, - // deletedRecords: Array of ids that were marked as deleted by the server. - compareClientWithServer(clientItems, serverItems) { - clientItems = clientItems.map(item => this.normalizeClientItem(item)); - serverItems = serverItems.map(item => this.normalizeServerItem(item)); - let problems = this.emptyProblemData(); - let seenServer = new Map(); - let serverDeleted = new Set(); - let allRecords = new Map(); - - for (let record of serverItems) { - let id = record[this.idProp]; - if (!id) { - ++problems.missingIDs; - continue; - } - if (record.deleted) { - serverDeleted.add(record); - } else { - let possibleDupe = seenServer.get(id); - if (possibleDupe) { - problems.duplicates.push(id); - } else { - seenServer.set(id, record); - allRecords.set(id, { server: record, client: null, }); - } - record.understood = this.clientUnderstands(record); - } - } - - let recordPairs = []; - let seenClient = new Map(); - for (let record of clientItems) { - let id = record[this.idProp]; - record.shouldSync = this.syncedByClient(record); - seenClient.set(id, record); - let combined = allRecords.get(id); - if (combined) { - combined.client = record; - } else { - allRecords.set(id, { client: record, server: null }); - } - } - - for (let [id, { server, client }] of allRecords) { - if (!client && !server) { - throw new Error("Impossible: no client or server record for " + id); - } else if (server && !client) { - if (server.understood) { - problems.clientMissing.push(id); - } - } else if (client && !server) { - if (client.shouldSync) { - problems.serverMissing.push(id); - } - } else { - if (!client.shouldSync) { - if (!problems.serverUnexpected.includes(id)) { - problems.serverUnexpected.push(id); - } - continue; - } - let differences = this.getDifferences(client, server); - if (differences && differences.length) { - problems.differences.push({ id, differences }); - } - } - } - return { - problemData: problems, - clientRecords: clientItems, - records: serverItems, - deletedRecords: [...serverDeleted] - }; - } -} - -// Default to 0, some engines may override. -CollectionValidator.prototype.version = 0; diff --git a/services/sync/modules/constants.js b/services/sync/modules/constants.js index f70bbd61c..88464f023 100644 --- a/services/sync/modules/constants.js +++ b/services/sync/modules/constants.js @@ -45,7 +45,7 @@ MAX_IGNORE_ERROR_COUNT: 5, // Backoff intervals MINIMUM_BACKOFF_INTERVAL: 15 * 60 * 1000, // 15 minutes -MAXIMUM_BACKOFF_INTERVAL: 8 * 60 * 60 * 1000, // 8 hours +MAXIMUM_BACKOFF_INTERVAL: 8 * 60 * 60 * 1000, // 8 hours // HMAC event handling timeout. // 10 minutes: a compromise between the multi-desktop sync interval @@ -76,10 +76,6 @@ PASSWORDS_STORE_BATCH_SIZE: 50, // same as MOBILE_BATCH_SIZE ADDONS_STORE_BATCH_SIZE: 1000000, // process all addons at once APPS_STORE_BATCH_SIZE: 50, // same as MOBILE_BATCH_SIZE -// Default batch size for download batching -// (how many records are fetched at a time from the server when batching is used). -DEFAULT_DOWNLOAD_BATCH_SIZE: 1000, - // score thresholds for early syncs SINGLE_USER_THRESHOLD: 1000, MULTI_DEVICE_THRESHOLD: 300, @@ -98,16 +94,13 @@ SCORE_UPDATE_DELAY: 100, // observed spurious idle/back events and short enough to pre-empt user activity. IDLE_OBSERVER_BACK_DELAY: 100, -// Max number of records or bytes to upload in a single POST - we'll do multiple POSTS if either -// MAX_UPLOAD_RECORDS or MAX_UPLOAD_BYTES is hit) +// Number of records to upload in a single POST (multiple POSTS if exceeded) +// FIXME: Record size limit is 256k (new cluster), so this can be quite large! +// (Bug 569295) MAX_UPLOAD_RECORDS: 100, -MAX_UPLOAD_BYTES: 1024 * 1023, // just under 1MB MAX_HISTORY_UPLOAD: 5000, MAX_HISTORY_DOWNLOAD: 5000, -// TTL of the message sent to another device when sending a tab -NOTIFY_TAB_SENT_TTL_SECS: 1 * 3600, // 1 hour - // Top-level statuses: STATUS_OK: "success.status_ok", SYNC_FAILED: "error.sync.failed", @@ -130,6 +123,7 @@ LOGIN_FAILED_NETWORK_ERROR: "error.login.reason.network", LOGIN_FAILED_SERVER_ERROR: "error.login.reason.server", LOGIN_FAILED_INVALID_PASSPHRASE: "error.login.reason.recoverykey", LOGIN_FAILED_LOGIN_REJECTED: "error.login.reason.account", +LOGIN_FAILED_NOT_READY: "error.login.reason.initializing", // sync failure status codes METARECORD_DOWNLOAD_FAIL: "error.sync.reason.metarecord_download_fail", @@ -152,8 +146,6 @@ ENGINE_UNKNOWN_FAIL: "error.engine.reason.unknown_fail", ENGINE_APPLY_FAIL: "error.engine.reason.apply_fail", ENGINE_METARECORD_DOWNLOAD_FAIL: "error.engine.reason.metarecord_download_fail", ENGINE_METARECORD_UPLOAD_FAIL: "error.engine.reason.metarecord_upload_fail", -// an upload failure where the batch was interrupted with a 412 -ENGINE_BATCH_INTERRUPTED: "error.engine.reason.batch_interrupted", JPAKE_ERROR_CHANNEL: "jpake.error.channel", JPAKE_ERROR_NETWORK: "jpake.error.network", @@ -182,6 +174,7 @@ kFirstSyncChoiceNotMade: "User has not selected an action for firs // Application IDs FIREFOX_ID: "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}", +PALEMOON_ID: "{8de7fcbb-c55c-4fbe-bfc5-fc555c87dbc4}", FENNEC_ID: "{a23983c0-fd0e-11dc-95ff-0800200c9a66}", SEAMONKEY_ID: "{92650c4d-4b8e-4d2a-b7eb-24ecf4f6b63a}", TEST_HARNESS_ID: "xuth@mozilla.org", @@ -192,6 +185,8 @@ MIN_PASS_LENGTH: 8, DEVICE_TYPE_DESKTOP: "desktop", DEVICE_TYPE_MOBILE: "mobile", +LOG_DATE_FORMAT: "%Y-%m-%d %H:%M:%S", + })) { this[key] = val; this.EXPORTED_SYMBOLS.push(key); diff --git a/services/sync/modules/engines.js b/services/sync/modules/engines.js index 1eaa1863a..4767a1103 100644 --- a/services/sync/modules/engines.js +++ b/services/sync/modules/engines.js @@ -7,8 +7,7 @@ this.EXPORTED_SYMBOLS = [ "Engine", "SyncEngine", "Tracker", - "Store", - "Changeset" + "Store" ]; var {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components; @@ -16,15 +15,13 @@ var {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components; Cu.import("resource://services-common/async.js"); Cu.import("resource://gre/modules/Log.jsm"); Cu.import("resource://services-common/observers.js"); +Cu.import("resource://services-common/utils.js"); Cu.import("resource://services-sync/constants.js"); Cu.import("resource://services-sync/identity.js"); Cu.import("resource://services-sync/record.js"); Cu.import("resource://services-sync/resource.js"); Cu.import("resource://services-sync/util.js"); -XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts", - "resource://gre/modules/FxAccounts.jsm"); - /* * Trackers are associated with a single engine and deal with * listening for changes to their particular data type. @@ -132,12 +129,6 @@ Tracker.prototype = { this._ignored.splice(index, 1); }, - _saveChangedID(id, when) { - this._log.trace(`Adding changed ID: ${id}, ${JSON.stringify(when)}`); - this.changedIDs[id] = when; - this.saveChangedIDs(this.onSavedChangedIDs); - }, - addChangedID: function (id, when) { if (!id) { this._log.warn("Attempted to add undefined ID to tracker"); @@ -150,12 +141,14 @@ Tracker.prototype = { // Default to the current time in seconds if no time is provided. if (when == null) { - when = this._now(); + when = Math.floor(Date.now() / 1000); } // Add/update the entry if we have a newer time. if ((this.changedIDs[id] || -Infinity) < when) { - this._saveChangedID(id, when); + this._log.trace("Adding changed ID: " + id + ", " + when); + this.changedIDs[id] = when; + this.saveChangedIDs(this.onSavedChangedIDs); } return true; @@ -183,10 +176,6 @@ Tracker.prototype = { this.saveChangedIDs(); }, - _now() { - return Date.now() / 1000; - }, - _isTracking: false, // Override these in your subclasses. @@ -314,18 +303,14 @@ Store.prototype = { for (let record of records) { try { this.applyIncoming(record); + } catch (ex if (ex.code == Engine.prototype.eEngineAbortApplyIncoming)) { + // This kind of exception should have a 'cause' attribute, which is an + // originating exception. + // ex.cause will carry its stack with it when rethrown. + throw ex.cause; } catch (ex) { - if (ex.code == Engine.prototype.eEngineAbortApplyIncoming) { - // This kind of exception should have a 'cause' attribute, which is an - // originating exception. - // ex.cause will carry its stack with it when rethrown. - throw ex.cause; - } - if (Async.isShutdownException(ex)) { - throw ex; - } - this._log.warn("Failed to apply incoming record " + record.id, ex); - this.engine._noteApplyFailure(); + this._log.warn("Failed to apply incoming record " + record.id); + this._log.warn("Encountered exception: ", ex); failed.push(record.id); } }; @@ -593,11 +578,16 @@ EngineManager.prototype = { this._engines[name] = engine; } } catch (ex) { + this._log.error("Engine init error: ", ex); + + let mesg = ex.message ? ex.message : ex; let name = engineObject || ""; name = name.prototype || ""; name = name.name || ""; - this._log.error(`Could not initialize engine ${name}`, ex); + let out = "Could not initialize engine '" + name + "': " + mesg; + this._log.error(out); + return engineObject; } }, @@ -643,17 +633,16 @@ Engine.prototype = { // Signal to the engine that processing further records is pointless. eEngineAbortApplyIncoming: "error.engine.abort.applyincoming", - // Should we keep syncing if we find a record that cannot be uploaded (ever)? - // If this is false, we'll throw, otherwise, we'll ignore the record and - // continue. This currently can only happen due to the record being larger - // than the record upload limit. - allowSkippedRecord: true, - get prefName() { return this.name; }, get enabled() { + // XXX: Disable non-functional add-ons syncing for the time being + // This check can go away when add-on syncing is addressed + if (this.prefName == "addons") + return false; + return Svc.Prefs.get("engine." + this.prefName, false); }, @@ -711,15 +700,6 @@ Engine.prototype = { wipeClient: function () { this._notify("wipe-client", this.name, this._wipeClient)(); - }, - - /** - * If one exists, initialize and return a validator for this engine (which - * must have a `validate(engine)` method that returns a promise to an object - * with a getSummary method). Otherwise return null. - */ - getValidator: function () { - return null; } }; @@ -813,11 +793,7 @@ SyncEngine.prototype = { return this._toFetch; }, set toFetch(val) { - let cb = (error) => { - if (error) { - this._log.error("Failed to read JSON records to fetch", error); - } - } + let cb = (error) => this._log.error("Failed to read JSON records to fetch: ", error); // Coerce the array to a string for more efficient comparison. if (val + "" == this._toFetch) { return; @@ -881,8 +857,9 @@ SyncEngine.prototype = { }, /* - * Returns a changeset for this sync. Engine implementations can override this - * method to bypass the tracker for certain or all changed items. + * Returns a mapping of IDs -> changed timestamp. Engine implementations + * can override this method to bypass the tracker for certain or all + * changed items. */ getChangedIDs: function () { return this._tracker.changedIDs; @@ -950,16 +927,20 @@ SyncEngine.prototype = { // this._modified to the tracker. this.lastSyncLocal = Date.now(); if (this.lastSync) { - this._modified = this.pullNewChanges(); + this._modified = this.getChangedIDs(); } else { + // Mark all items to be uploaded, but treat them as changed from long ago this._log.debug("First sync, uploading all items"); - this._modified = this.pullAllChanges(); + this._modified = {}; + for (let id in this._store.getAllIDs()) { + this._modified[id] = 0; + } } // Clear the tracker now. If the sync fails we'll add the ones we failed // to upload back. this._tracker.clearChangedIDs(); - this._log.info(this._modified.count() + + this._log.info(Object.keys(this._modified).length + " outgoing items pre-reconciliation"); // Keep track of what to delete at the end of sync @@ -970,7 +951,7 @@ SyncEngine.prototype = { * A tiny abstraction to make it easier to test incoming record * application. */ - itemSource: function () { + _itemSource: function () { return new Collection(this.engineURL, this._recordObj, this.service); }, @@ -987,7 +968,7 @@ SyncEngine.prototype = { let isMobile = (Svc.Prefs.get("client.type") == "mobile"); if (!newitems) { - newitems = this.itemSource(); + newitems = this._itemSource(); } if (this._defaultSort) { @@ -1024,12 +1005,9 @@ SyncEngine.prototype = { try { failed = failed.concat(this._store.applyIncomingBatch(applyBatch)); } catch (ex) { - if (Async.isShutdownException(ex)) { - throw ex; - } // Catch any error that escapes from applyIncomingBatch. At present // those will all be abort events. - this._log.warn("Got exception, aborting processIncoming", ex); + this._log.warn("Got exception, aborting processIncoming. ", ex); aborting = ex; } this._tracker.ignoreAll = false; @@ -1074,10 +1052,7 @@ SyncEngine.prototype = { try { try { item.decrypt(key); - } catch (ex) { - if (!Utils.isHMACMismatch(ex)) { - throw ex; - } + } catch (ex if Utils.isHMACMismatch(ex)) { let strategy = self.handleHMACMismatch(item, true); if (strategy == SyncEngine.kRecoveryStrategy.retry) { // You only get one retry. @@ -1087,10 +1062,7 @@ SyncEngine.prototype = { key = self.service.collectionKeys.keyForCollection(self.name); item.decrypt(key); strategy = null; - } catch (ex) { - if (!Utils.isHMACMismatch(ex)) { - throw ex; - } + } catch (ex if Utils.isHMACMismatch(ex)) { strategy = self.handleHMACMismatch(item, false); } } @@ -1103,8 +1075,7 @@ SyncEngine.prototype = { self._log.debug("Ignoring second retry suggestion."); // Fall through to error case. case SyncEngine.kRecoveryStrategy.error: - self._log.warn("Error decrypting record", ex); - self._noteApplyFailure(); + self._log.warn("Error decrypting record: ", ex); failed.push(item.id); return; case SyncEngine.kRecoveryStrategy.ignore: @@ -1114,11 +1085,7 @@ SyncEngine.prototype = { } } } catch (ex) { - if (Async.isShutdownException(ex)) { - throw ex; - } - self._log.warn("Error decrypting record", ex); - self._noteApplyFailure(); + self._log.warn("Error decrypting record: ", ex); failed.push(item.id); return; } @@ -1126,20 +1093,15 @@ SyncEngine.prototype = { let shouldApply; try { shouldApply = self._reconcile(item); + } catch (ex if (ex.code == Engine.prototype.eEngineAbortApplyIncoming)) { + self._log.warn("Reconciliation failed: aborting incoming processing."); + failed.push(item.id); + aborting = ex.cause; } catch (ex) { - if (ex.code == Engine.prototype.eEngineAbortApplyIncoming) { - self._log.warn("Reconciliation failed: aborting incoming processing."); - self._noteApplyFailure(); - failed.push(item.id); - aborting = ex.cause; - } else if (!Async.isShutdownException(ex)) { - self._log.warn("Failed to reconcile incoming record " + item.id, ex); - self._noteApplyFailure(); - failed.push(item.id); - return; - } else { - throw ex; - } + self._log.warn("Failed to reconcile incoming record " + item.id); + self._log.warn("Encountered exception: ", ex); + failed.push(item.id); + return; } if (shouldApply) { @@ -1158,7 +1120,7 @@ SyncEngine.prototype = { // Only bother getting data from the server if there's new things if (this.lastModified == null || this.lastModified > this.lastSync) { - let resp = newitems.getBatched(); + let resp = newitems.get(); doApplyBatchAndPersistFailed.call(this); if (!resp.success) { resp.failureCode = ENGINE_DOWNLOAD_FAIL; @@ -1243,13 +1205,7 @@ SyncEngine.prototype = { // Apply remaining items. doApplyBatchAndPersistFailed.call(this); - count.newFailed = this.previousFailed.reduce((count, engine) => { - if (failedInPreviousSync.indexOf(engine) == -1) { - count++; - this._noteApplyNewFailure(); - } - return count; - }, 0); + count.newFailed = Utils.arraySub(this.previousFailed, failedInPreviousSync).length; count.succeeded = Math.max(0, count.applied - count.failed); this._log.info(["Records:", count.applied, "applied,", @@ -1260,14 +1216,6 @@ SyncEngine.prototype = { Observers.notify("weave:engine:sync:applied", count, this.name); }, - _noteApplyFailure: function () { - // here would be a good place to record telemetry... - }, - - _noteApplyNewFailure: function () { - // here would be a good place to record telemetry... - }, - /** * Find a GUID of an item that is a duplicate of the incoming item but happens * to have a different GUID @@ -1278,16 +1226,6 @@ SyncEngine.prototype = { // By default, assume there's no dupe items for the engine }, - // Called when the server has a record marked as deleted, but locally we've - // changed it more recently than the deletion. If we return false, the - // record will be deleted locally. If we return true, we'll reupload the - // record to the server -- any extra work that's needed as part of this - // process should be done at this point (such as mark the record's parent - // for reuploading in the case of bookmarks). - _shouldReviveRemotelyDeletedRecord(remoteItem) { - return true; - }, - _deleteId: function (id) { this._tracker.removeChangedID(id); @@ -1298,18 +1236,6 @@ SyncEngine.prototype = { this._delete.ids.push(id); }, - _switchItemToDupe(localDupeGUID, incomingItem) { - // The local, duplicate ID is always deleted on the server. - this._deleteId(localDupeGUID); - - // We unconditionally change the item's ID in case the engine knows of - // an item but doesn't expose it through itemExists. If the API - // contract were stronger, this could be changed. - this._log.debug("Switching local ID to incoming: " + localDupeGUID + " -> " + - incomingItem.id); - this._store.changeItemID(localDupeGUID, incomingItem.id); - }, - /** * Reconcile incoming record with local state. * @@ -1329,12 +1255,12 @@ SyncEngine.prototype = { // because some state may change during the course of this function and we // need to operate on the original values. let existsLocally = this._store.itemExists(item.id); - let locallyModified = this._modified.has(item.id); + let locallyModified = item.id in this._modified; // TODO Handle clock drift better. Tracked in bug 721181. let remoteAge = AsyncResource.serverTime - item.modified; let localAge = locallyModified ? - (Date.now() / 1000 - this._modified.getModifiedTimestamp(item.id)) : null; + (Date.now() / 1000 - this._modified[item.id]) : null; let remoteIsNewer = remoteAge < localAge; this._log.trace("Reconciling " + item.id + ". exists=" + @@ -1363,18 +1289,15 @@ SyncEngine.prototype = { "exists and isn't modified."); return true; } - this._log.trace("Incoming record is deleted but we had local changes."); - - if (remoteIsNewer) { - this._log.trace("Remote record is newer -- deleting local record."); - return true; - } - // If the local record is newer, we defer to individual engines for - // how to handle this. By default, we revive the record. - let willRevive = this._shouldReviveRemotelyDeletedRecord(item); - this._log.trace("Local record is newer -- reviving? " + willRevive); - return !willRevive; + // TODO As part of bug 720592, determine whether we should do more here. + // In the case where the local changes are newer, it is quite possible + // that the local client will restore data a remote client had tried to + // delete. There might be a good reason for that delete and it might be + // enexpected for this client to restore that data. + this._log.trace("Incoming record is deleted but we had local changes. " + + "Applying the youngest record."); + return remoteIsNewer; } // At this point the incoming record is not for a deletion and must have @@ -1386,32 +1309,40 @@ SyncEngine.prototype = { // refresh the metadata collected above. See bug 710448 for the history // of this logic. if (!existsLocally) { - let localDupeGUID = this._findDupe(item); - if (localDupeGUID) { - this._log.trace("Local item " + localDupeGUID + " is a duplicate for " + + let dupeID = this._findDupe(item); + if (dupeID) { + this._log.trace("Local item " + dupeID + " is a duplicate for " + "incoming item " + item.id); + // The local, duplicate ID is always deleted on the server. + this._deleteId(dupeID); + // The current API contract does not mandate that the ID returned by // _findDupe() actually exists. Therefore, we have to perform this // check. - existsLocally = this._store.itemExists(localDupeGUID); + existsLocally = this._store.itemExists(dupeID); + + // We unconditionally change the item's ID in case the engine knows of + // an item but doesn't expose it through itemExists. If the API + // contract were stronger, this could be changed. + this._log.debug("Switching local ID to incoming: " + dupeID + " -> " + + item.id); + this._store.changeItemID(dupeID, item.id); // If the local item was modified, we carry its metadata forward so // appropriate reconciling can be performed. - if (this._modified.has(localDupeGUID)) { + if (dupeID in this._modified) { locallyModified = true; - localAge = this._tracker._now() - this._modified.getModifiedTimestamp(localDupeGUID); + localAge = Date.now() / 1000 - this._modified[dupeID]; remoteIsNewer = remoteAge < localAge; - this._modified.swap(localDupeGUID, item.id); + this._modified[item.id] = this._modified[dupeID]; + delete this._modified[dupeID]; } else { locallyModified = false; localAge = null; } - // Tell the engine to do whatever it needs to switch the items. - this._switchItemToDupe(localDupeGUID, item); - this._log.debug("Local item after duplication: age=" + localAge + "; modified=" + locallyModified + "; exists=" + existsLocally); @@ -1440,7 +1371,7 @@ SyncEngine.prototype = { if (remoteIsNewer) { this._log.trace("Applying incoming because local item was deleted " + "before the incoming item was changed."); - this._modified.delete(item.id); + delete this._modified[item.id]; return true; } @@ -1466,7 +1397,7 @@ SyncEngine.prototype = { this._log.trace("Ignoring incoming item because the local item is " + "identical."); - this._modified.delete(item.id); + delete this._modified[item.id]; return false; } @@ -1491,97 +1422,69 @@ SyncEngine.prototype = { _uploadOutgoing: function () { this._log.trace("Uploading local changes to server."); - let modifiedIDs = this._modified.ids(); + let modifiedIDs = Object.keys(this._modified); if (modifiedIDs.length) { this._log.trace("Preparing " + modifiedIDs.length + " outgoing records"); - let counts = { sent: modifiedIDs.length, failed: 0 }; - // collection we'll upload let up = new Collection(this.engineURL, null, this.service); + let count = 0; - let failed = []; - let successful = []; - let handleResponse = (resp, batchOngoing = false) => { - // Note: We don't want to update this.lastSync, or this._modified until - // the batch is complete, however we want to remember success/failure - // indicators for when that happens. + // Upload what we've got so far in the collection + let doUpload = Utils.bind2(this, function(desc) { + this._log.info("Uploading " + desc + " of " + modifiedIDs.length + + " records"); + let resp = up.post(); if (!resp.success) { this._log.debug("Uploading records failed: " + resp); - resp.failureCode = resp.status == 412 ? ENGINE_BATCH_INTERRUPTED : ENGINE_UPLOAD_FAIL; + resp.failureCode = ENGINE_UPLOAD_FAIL; throw resp; } // Update server timestamp from the upload. - failed = failed.concat(Object.keys(resp.obj.failed)); - successful = successful.concat(resp.obj.success); - - if (batchOngoing) { - // Nothing to do yet - return; - } - // Advance lastSync since we've finished the batch. let modified = resp.headers["x-weave-timestamp"]; - if (modified > this.lastSync) { + if (modified > this.lastSync) this.lastSync = modified; - } - if (failed.length && this._log.level <= Log.Level.Debug) { + + let failed_ids = Object.keys(resp.obj.failed); + if (failed_ids.length) this._log.debug("Records that will be uploaded again because " + "the server couldn't store them: " - + failed.join(", ")); - } - - counts.failed += failed.length; + + failed_ids.join(", ")); - for (let id of successful) { - this._modified.delete(id); + // Clear successfully uploaded objects. + for (let id of resp.obj.success) { + delete this._modified[id]; } - this._onRecordsWritten(successful, failed); - - // clear for next batch - failed.length = 0; - successful.length = 0; - }; - - let postQueue = up.newPostQueue(this._log, this.lastSync, handleResponse); + up.clearRecords(); + }); for (let id of modifiedIDs) { - let out; - let ok = false; try { - out = this._createRecord(id); + let out = this._createRecord(id); if (this._log.level <= Log.Level.Trace) this._log.trace("Outgoing: " + out); out.encrypt(this.service.collectionKeys.keyForCollection(this.name)); - ok = true; - } catch (ex) { - if (Async.isShutdownException(ex)) { - throw ex; - } - this._log.warn("Error creating record", ex); + up.pushData(out); } - if (ok) { - let { enqueued, error } = postQueue.enqueue(out); - if (!enqueued) { - ++counts.failed; - if (!this.allowSkippedRecord) { - throw error; - } - } + catch(ex) { + this._log.warn("Error creating record: ", ex); } + + // Partial upload + if ((++count % MAX_UPLOAD_RECORDS) == 0) + doUpload((count - MAX_UPLOAD_RECORDS) + " - " + count + " out"); + this._store._sleep(0); } - postQueue.flush(true); - Observers.notify("weave:engine:sync:uploaded", counts, this.name); - } - }, - _onRecordsWritten(succeeded, failed) { - // Implement this method to take specific actions against successfully - // uploaded records and failed records. + // Final upload + if (count % MAX_UPLOAD_RECORDS > 0) + doUpload(count >= MAX_UPLOAD_RECORDS ? "last batch" : "all"); + } }, // Any cleanup necessary. @@ -1619,8 +1522,10 @@ SyncEngine.prototype = { } // Mark failed WBOs as changed again so they are reuploaded next time. - this.trackRemainingChanges(); - this._modified.clear(); + for (let [id, when] in Iterator(this._modified)) { + this._tracker.addChangedID(id, when); + } + this._modified = {}; }, _sync: function () { @@ -1656,11 +1561,9 @@ SyncEngine.prototype = { try { this._log.trace("Trying to decrypt a record from the server.."); test.get(); - } catch (ex) { - if (Async.isShutdownException(ex)) { - throw ex; - } - this._log.debug("Failed test decrypt", ex); + } + catch(ex) { + this._log.debug("Failed test decrypt: ", ex); } return canDecrypt; @@ -1706,108 +1609,5 @@ SyncEngine.prototype = { return (this.service.handleHMACEvent() && mayRetry) ? SyncEngine.kRecoveryStrategy.retry : SyncEngine.kRecoveryStrategy.error; - }, - - /** - * Returns a changeset containing all items in the store. The default - * implementation returns a changeset with timestamps from long ago, to - * ensure we always use the remote version if one exists. - * - * This function is only called for the first sync. Subsequent syncs call - * `pullNewChanges`. - * - * @return A `Changeset` object. - */ - pullAllChanges() { - let changeset = new Changeset(); - for (let id in this._store.getAllIDs()) { - changeset.set(id, 0); - } - return changeset; - }, - - /* - * Returns a changeset containing entries for all currently tracked items. - * The default implementation returns a changeset with timestamps indicating - * when the item was added to the tracker. - * - * @return A `Changeset` object. - */ - pullNewChanges() { - return new Changeset(this.getChangedIDs()); - }, - - /** - * Adds all remaining changeset entries back to the tracker, typically for - * items that failed to upload. This method is called at the end of each sync. - * - */ - trackRemainingChanges() { - for (let [id, change] of this._modified.entries()) { - this._tracker.addChangedID(id, change); - } - }, -}; - -/** - * A changeset is created for each sync in `Engine::get{Changed, All}IDs`, - * and stores opaque change data for tracked IDs. The default implementation - * only records timestamps, though engines can extend this to store additional - * data for each entry. - */ -class Changeset { - // Creates a changeset with an initial set of tracked entries. - constructor(changes = {}) { - this.changes = changes; - } - - // Returns the last modified time, in seconds, for an entry in the changeset. - // `id` is guaranteed to be in the set. - getModifiedTimestamp(id) { - return this.changes[id]; - } - - // Adds a change for a tracked ID to the changeset. - set(id, change) { - this.changes[id] = change; - } - - // Indicates whether an entry is in the changeset. - has(id) { - return id in this.changes; } - - // Deletes an entry from the changeset. Used to clean up entries for - // reconciled and successfully uploaded records. - delete(id) { - delete this.changes[id]; - } - - // Swaps two entries in the changeset. Used when reconciling duplicates that - // have local changes. - swap(oldID, newID) { - this.changes[newID] = this.changes[oldID]; - delete this.changes[oldID]; - } - - // Returns an array of all tracked IDs in this changeset. - ids() { - return Object.keys(this.changes); - } - - // Returns an array of `[id, change]` tuples. Used to repopulate the tracker - // with entries for failed uploads at the end of a sync. - entries() { - return Object.entries(this.changes); - } - - // Returns the number of entries in this changeset. - count() { - return this.ids().length; - } - - // Clears the changeset. - clear() { - this.changes = {}; - } -} +}; diff --git a/services/sync/modules/engines/addons.js b/services/sync/modules/engines/addons.js index 01dab58d1..3081e3e87 100644 --- a/services/sync/modules/engines/addons.js +++ b/services/sync/modules/engines/addons.js @@ -25,13 +25,10 @@ * * Synchronization is influenced by the following preferences: * + * - services.sync.addons.ignoreRepositoryChecking * - services.sync.addons.ignoreUserEnabledChanges * - services.sync.addons.trustedSourceHostnames * - * and also influenced by whether addons have repository caching enabled and - * whether they allow installation of addons from insecure options (both of - * which are themselves influenced by the "extensions." pref branch) - * * See the documentation in services-sync.js for the behavior of these prefs. */ "use strict"; @@ -44,7 +41,6 @@ Cu.import("resource://services-sync/engines.js"); Cu.import("resource://services-sync/record.js"); Cu.import("resource://services-sync/util.js"); Cu.import("resource://services-sync/constants.js"); -Cu.import("resource://services-sync/collection_validator.js"); Cu.import("resource://services-common/async.js"); Cu.import("resource://gre/modules/Preferences.jsm"); @@ -54,7 +50,7 @@ XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", XPCOMUtils.defineLazyModuleGetter(this, "AddonRepository", "resource://gre/modules/addons/AddonRepository.jsm"); -this.EXPORTED_SYMBOLS = ["AddonsEngine", "AddonValidator"]; +this.EXPORTED_SYMBOLS = ["AddonsEngine"]; // 7 days in milliseconds. const PRUNE_ADDON_CHANGES_THRESHOLD = 60 * 60 * 24 * 7 * 1000; @@ -177,7 +173,7 @@ AddonsEngine.prototype = { continue; } - if (!this.isAddonSyncable(addons[id])) { + if (!this._store.isAddonSyncable(addons[id])) { continue; } @@ -235,10 +231,6 @@ AddonsEngine.prototype = { let cb = Async.makeSpinningCallback(); this._reconciler.refreshGlobalState(cb); cb.wait(); - }, - - isAddonSyncable(addon, ignoreRepoCheck) { - return this._store.isAddonSyncable(addon, ignoreRepoCheck); } }; @@ -286,14 +278,6 @@ AddonsStore.prototype = { } } - // Ignore incoming records for which an existing non-syncable addon - // exists. - let existingMeta = this.reconciler.addons[record.addonID]; - if (existingMeta && !this.isAddonSyncable(existingMeta)) { - this._log.info("Ignoring incoming record for an existing but non-syncable addon", record.addonID); - return; - } - Store.prototype.applyIncoming.call(this, record); }, @@ -314,14 +298,6 @@ AddonsStore.prototype = { // engine and the record will try to be applied later. let results = cb.wait(); - if (results.skipped.includes(record.addonID)) { - this._log.info("Add-on skipped: " + record.addonID); - // Just early-return for skipped addons - we don't want to arrange to - // try again next time because the condition that caused up to skip - // will remain true for this addon forever. - return; - } - let addon; for (let a of results.addons) { if (a.id == record.addonID) { @@ -499,7 +475,7 @@ AddonsStore.prototype = { } this._log.info("Uninstalling add-on as part of wipe: " + addon.id); - Utils.catch.call(this, () => addon.uninstall())(); + Utils.catch(addon.uninstall)(); } }, @@ -538,22 +514,16 @@ AddonsStore.prototype = { * * @param addon * Addon instance - * @param ignoreRepoCheck - * Should we skip checking the Addons repository (primarially useful - * for testing and validation). * @return Boolean indicating whether it is appropriate for Sync */ - isAddonSyncable: function isAddonSyncable(addon, ignoreRepoCheck = false) { + isAddonSyncable: function isAddonSyncable(addon) { // Currently, we limit syncable add-ons to those that are: // 1) In a well-defined set of types // 2) Installed in the current profile // 3) Not installed by a foreign entity (i.e. installed by the app) // since they act like global extensions. // 4) Is not a hotfix. - // 5) The addons XPIProvider doesn't veto it (i.e not being installed in - // the profile directory, or any other reasons it says the addon can't - // be synced) - // 6) Are installed from AMO + // 5) Are installed from AMO // We could represent the test as a complex boolean expression. We go the // verbose route so the failure reason is logged. @@ -573,12 +543,6 @@ AddonsStore.prototype = { return false; } - // If the addon manager says it's not syncable, we skip it. - if (!addon.isSyncable) { - this._log.debug(addon.id + " not syncable: vetoed by the addon manager."); - return false; - } - // This may be too aggressive. If an add-on is downloaded from AMO and // manually placed in the profile directory, foreignInstall will be set. // Arguably, that add-on should be syncable. @@ -589,20 +553,15 @@ AddonsStore.prototype = { } // Ignore hotfix extensions (bug 741670). The pref may not be defined. - // XXX - note that addon.isSyncable will be false for hotfix addons, so - // this check isn't strictly necessary - except for Sync tests which aren't - // setup to create a "real" hotfix addon. This can be removed once those - // tests are fixed (but keeping it doesn't hurt either) if (this._extensionsPrefs.get("hotfix.id", null) == addon.id) { this._log.debug(addon.id + " not syncable: is a hotfix."); return false; } - // If the AddonRepository's cache isn't enabled (which it typically isn't - // in tests), getCachedAddonByID always returns null - so skip the check - // in that case. We also provide a way to specifically opt-out of the check - // even if the cache is enabled, which is used by the validators. - if (ignoreRepoCheck || !AddonRepository.cacheEnabled) { + // We provide a back door to skip the repository checking of an add-on. + // This is utilized by the tests to make testing easier. Users could enable + // this, but it would sacrifice security. + if (Svc.Prefs.get("addons.ignoreRepositoryChecking", false)) { return true; } @@ -745,69 +704,3 @@ AddonsTracker.prototype = { this.reconciler.stopListening(); }, }; - -class AddonValidator extends CollectionValidator { - constructor(engine = null) { - super("addons", "id", [ - "addonID", - "enabled", - "applicationID", - "source" - ]); - this.engine = engine; - } - - getClientItems() { - return Promise.all([ - new Promise(resolve => - AddonManager.getAllAddons(resolve)), - new Promise(resolve => - AddonManager.getAddonsWithOperationsByTypes(["extension", "theme"], resolve)), - ]).then(([installed, addonsWithPendingOperation]) => { - // Addons pending install won't be in the first list, but addons pending - // uninstall/enable/disable will be in both lists. - let all = new Map(installed.map(addon => [addon.id, addon])); - for (let addon of addonsWithPendingOperation) { - all.set(addon.id, addon); - } - // Convert to an array since Map.prototype.values returns an iterable - return [...all.values()]; - }); - } - - normalizeClientItem(item) { - let enabled = !item.userDisabled; - if (item.pendingOperations & AddonManager.PENDING_ENABLE) { - enabled = true; - } else if (item.pendingOperations & AddonManager.PENDING_DISABLE) { - enabled = false; - } - return { - enabled, - id: item.syncGUID, - addonID: item.id, - applicationID: Services.appinfo.ID, - source: "amo", // check item.foreignInstall? - original: item - }; - } - - normalizeServerItem(item) { - let guid = this.engine._findDupe(item); - if (guid) { - item.id = guid; - } - return item; - } - - clientUnderstands(item) { - return item.applicationID === Services.appinfo.ID; - } - - syncedByClient(item) { - return !item.original.hidden && - !item.original.isSystem && - !(item.original.pendingOperations & AddonManager.PENDING_UNINSTALL) && - this.engine.isAddonSyncable(item.original, true); - } -} diff --git a/services/sync/modules/engines/bookmarks.js b/services/sync/modules/engines/bookmarks.js index 76a198a8b..41283c06d 100644 --- a/services/sync/modules/engines/bookmarks.js +++ b/services/sync/modules/engines/bookmarks.js @@ -11,7 +11,6 @@ var Ci = Components.interfaces; var Cu = Components.utils; Cu.import("resource://gre/modules/PlacesUtils.jsm"); -Cu.import("resource://gre/modules/PlacesSyncUtils.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://services-common/async.js"); Cu.import("resource://services-sync/constants.js"); @@ -20,57 +19,21 @@ Cu.import("resource://services-sync/record.js"); Cu.import("resource://services-sync/util.js"); Cu.import("resource://gre/modules/Task.jsm"); Cu.import("resource://gre/modules/PlacesBackups.jsm"); -XPCOMUtils.defineLazyModuleGetter(this, "BookmarkValidator", - "resource://services-sync/bookmark_validator.js"); -XPCOMUtils.defineLazyGetter(this, "PlacesBundle", () => { - let bundleService = Cc["@mozilla.org/intl/stringbundle;1"] - .getService(Ci.nsIStringBundleService); - return bundleService.createBundle("chrome://places/locale/places.properties"); -}); - -const ANNOS_TO_TRACK = [PlacesSyncUtils.bookmarks.DESCRIPTION_ANNO, - PlacesSyncUtils.bookmarks.SIDEBAR_ANNO, + +const ALLBOOKMARKS_ANNO = "AllBookmarks"; +const DESCRIPTION_ANNO = "bookmarkProperties/description"; +const SIDEBAR_ANNO = "bookmarkProperties/loadInSidebar"; +const MOBILEROOT_ANNO = "mobile/bookmarksRoot"; +const MOBILE_ANNO = "MobileBookmarks"; +const EXCLUDEBACKUP_ANNO = "places/excludeFromBackup"; +const SMART_BOOKMARKS_ANNO = "Places/SmartBookmark"; +const PARENT_ANNO = "sync/parent"; +const ORGANIZERQUERY_ANNO = "PlacesOrganizer/OrganizerQuery"; +const ANNOS_TO_TRACK = [DESCRIPTION_ANNO, SIDEBAR_ANNO, PlacesUtils.LMANNO_FEEDURI, PlacesUtils.LMANNO_SITEURI]; const SERVICE_NOT_SUPPORTED = "Service not supported on this platform"; const FOLDER_SORTINDEX = 1000000; -const { - SOURCE_SYNC, - SOURCE_IMPORT, - SOURCE_IMPORT_REPLACE, -} = Ci.nsINavBookmarksService; - -const SQLITE_MAX_VARIABLE_NUMBER = 999; - -const ORGANIZERQUERY_ANNO = "PlacesOrganizer/OrganizerQuery"; -const ALLBOOKMARKS_ANNO = "AllBookmarks"; -const MOBILE_ANNO = "MobileBookmarks"; - -// The tracker ignores changes made by bookmark import and restore, and -// changes made by Sync. We don't need to exclude `SOURCE_IMPORT`, but both -// import and restore fire `bookmarks-restore-*` observer notifications, and -// the tracker doesn't currently distinguish between the two. -const IGNORED_SOURCES = [SOURCE_SYNC, SOURCE_IMPORT, SOURCE_IMPORT_REPLACE]; - -// Returns the constructor for a bookmark record type. -function getTypeObject(type) { - switch (type) { - case "bookmark": - case "microsummary": - return Bookmark; - case "query": - return BookmarkQuery; - case "folder": - return BookmarkFolder; - case "livemark": - return Livemark; - case "separator": - return BookmarkSeparator; - case "item": - return PlacesItem; - } - return null; -} this.PlacesItem = function PlacesItem(collection, id, type) { CryptoWrapper.call(this, collection, id); @@ -89,32 +52,26 @@ PlacesItem.prototype = { }, getTypeObject: function PlacesItem_getTypeObject(type) { - let recordObj = getTypeObject(type); - if (!recordObj) { - throw new Error("Unknown places item object type: " + type); + switch (type) { + case "bookmark": + case "microsummary": + return Bookmark; + case "query": + return BookmarkQuery; + case "folder": + return BookmarkFolder; + case "livemark": + return Livemark; + case "separator": + return BookmarkSeparator; + case "item": + return PlacesItem; } - return recordObj; + throw "Unknown places item object type: " + type; }, __proto__: CryptoWrapper.prototype, _logName: "Sync.Record.PlacesItem", - - // Converts the record to a Sync bookmark object that can be passed to - // `PlacesSyncUtils.bookmarks.{insert, update}`. - toSyncBookmark() { - return { - kind: this.type, - syncId: this.id, - parentSyncId: this.parentid, - }; - }, - - // Populates the record from a Sync bookmark object returned from - // `PlacesSyncUtils.bookmarks.fetch`. - fromSyncBookmark(item) { - this.parentid = item.parentSyncId; - this.parentName = item.parentTitle; - }, }; Utils.deferGetSet(PlacesItem, @@ -127,27 +84,6 @@ this.Bookmark = function Bookmark(collection, id, type) { Bookmark.prototype = { __proto__: PlacesItem.prototype, _logName: "Sync.Record.Bookmark", - - toSyncBookmark() { - let info = PlacesItem.prototype.toSyncBookmark.call(this); - info.title = this.title; - info.url = this.bmkUri; - info.description = this.description; - info.loadInSidebar = this.loadInSidebar; - info.tags = this.tags; - info.keyword = this.keyword; - return info; - }, - - fromSyncBookmark(item) { - PlacesItem.prototype.fromSyncBookmark.call(this, item); - this.title = item.title; - this.bmkUri = item.url.href; - this.description = item.description; - this.loadInSidebar = item.loadInSidebar; - this.tags = item.tags; - this.keyword = item.keyword; - }, }; Utils.deferGetSet(Bookmark, @@ -161,19 +97,6 @@ this.BookmarkQuery = function BookmarkQuery(collection, id) { BookmarkQuery.prototype = { __proto__: Bookmark.prototype, _logName: "Sync.Record.BookmarkQuery", - - toSyncBookmark() { - let info = Bookmark.prototype.toSyncBookmark.call(this); - info.folder = this.folderName; - info.query = this.queryId; - return info; - }, - - fromSyncBookmark(item) { - Bookmark.prototype.fromSyncBookmark.call(this, item); - this.folderName = item.folder; - this.queryId = item.query; - }, }; Utils.deferGetSet(BookmarkQuery, @@ -186,20 +109,6 @@ this.BookmarkFolder = function BookmarkFolder(collection, id, type) { BookmarkFolder.prototype = { __proto__: PlacesItem.prototype, _logName: "Sync.Record.Folder", - - toSyncBookmark() { - let info = PlacesItem.prototype.toSyncBookmark.call(this); - info.description = this.description; - info.title = this.title; - return info; - }, - - fromSyncBookmark(item) { - PlacesItem.prototype.fromSyncBookmark.call(this, item); - this.title = item.title; - this.description = item.description; - this.children = item.childSyncIds; - }, }; Utils.deferGetSet(BookmarkFolder, "cleartext", ["description", "title", @@ -211,21 +120,6 @@ this.Livemark = function Livemark(collection, id) { Livemark.prototype = { __proto__: BookmarkFolder.prototype, _logName: "Sync.Record.Livemark", - - toSyncBookmark() { - let info = BookmarkFolder.prototype.toSyncBookmark.call(this); - info.feed = this.feedUri; - info.site = this.siteUri; - return info; - }, - - fromSyncBookmark(item) { - BookmarkFolder.prototype.fromSyncBookmark.call(this, item); - this.feedUri = item.feed.href; - if (item.site) { - this.siteUri = item.site.href; - } - }, }; Utils.deferGetSet(Livemark, "cleartext", ["siteUri", "feedUri"]); @@ -236,15 +130,81 @@ this.BookmarkSeparator = function BookmarkSeparator(collection, id) { BookmarkSeparator.prototype = { __proto__: PlacesItem.prototype, _logName: "Sync.Record.Separator", - - fromSyncBookmark(item) { - PlacesItem.prototype.fromSyncBookmark.call(this, item); - this.pos = item.index; - }, }; Utils.deferGetSet(BookmarkSeparator, "cleartext", "pos"); + +let kSpecialIds = { + + // Special IDs. Note that mobile can attempt to create a record on + // dereference; special accessors are provided to prevent recursion within + // observers. + guids: ["menu", "places", "tags", "toolbar", "unfiled", "mobile"], + + // Create the special mobile folder to store mobile bookmarks. + createMobileRoot: function createMobileRoot() { + let root = PlacesUtils.placesRootId; + let mRoot = PlacesUtils.bookmarks.createFolder(root, "mobile", -1); + PlacesUtils.annotations.setItemAnnotation( + mRoot, MOBILEROOT_ANNO, 1, 0, PlacesUtils.annotations.EXPIRE_NEVER); + PlacesUtils.annotations.setItemAnnotation( + mRoot, EXCLUDEBACKUP_ANNO, 1, 0, PlacesUtils.annotations.EXPIRE_NEVER); + return mRoot; + }, + + findMobileRoot: function findMobileRoot(create) { + // Use the (one) mobile root if it already exists. + let root = PlacesUtils.annotations.getItemsWithAnnotation(MOBILEROOT_ANNO, {}); + if (root.length != 0) + return root[0]; + + if (create) + return this.createMobileRoot(); + + return null; + }, + + // Accessors for IDs. + isSpecialGUID: function isSpecialGUID(g) { + return this.guids.indexOf(g) != -1; + }, + + specialIdForGUID: function specialIdForGUID(guid, create) { + if (guid == "mobile") { + return this.findMobileRoot(create); + } + return this[guid]; + }, + + // Don't bother creating mobile: if it doesn't exist, this ID can't be it! + specialGUIDForId: function specialGUIDForId(id) { + for each (let guid in this.guids) + if (this.specialIdForGUID(guid, false) == id) + return guid; + return null; + }, + + get menu() { + return PlacesUtils.bookmarksMenuFolderId; + }, + get places() { + return PlacesUtils.placesRootId; + }, + get tags() { + return PlacesUtils.tagsFolderId; + }, + get toolbar() { + return PlacesUtils.toolbarFolderId; + }, + get unfiled() { + return PlacesUtils.unfiledBookmarksFolderId; + }, + get mobile() { + return this.findMobileRoot(true); + }, +}; + this.BookmarksEngine = function BookmarksEngine(service) { SyncEngine.call(this, "Bookmarks", service); } @@ -257,103 +217,68 @@ BookmarksEngine.prototype = { _defaultSort: "index", syncPriority: 4, - allowSkippedRecord: false, - - // A diagnostic helper to get the string value for a bookmark's URL given - // its ID. Always returns a string - on error will return a string in the - // form of "<description of error>" as this is purely for, eg, logging. - // (This means hitting the DB directly and we don't bother using a cached - // statement - we should rarely hit this.) - _getStringUrlForId(id) { - let url; - try { - let stmt = this._store._getStmt(` - SELECT h.url - FROM moz_places h - JOIN moz_bookmarks b ON h.id = b.fk - WHERE b.id = :id`); - stmt.params.id = id; - let rows = Async.querySpinningly(stmt, ["url"]); - url = rows.length == 0 ? "<not found>" : rows[0].url; - } catch (ex) { - if (Async.isShutdownException(ex)) { - throw ex; - } - if (ex instanceof Ci.mozIStorageError) { - url = `<failed: Storage error: ${ex.message} (${ex.result})>`; - } else { - url = `<failed: ${ex.toString()}>`; - } - } - return url; - }, - _guidMapFailed: false, - _buildGUIDMap: function _buildGUIDMap() { - let store = this._store; - let guidMap = {}; - let tree = Async.promiseSpinningly(PlacesUtils.promiseBookmarksTree("", { - includeItemIds: true - })); - function* walkBookmarksTree(tree, parent=null) { - if (tree) { - // Skip root node - if (parent) { - yield [tree, parent]; + _sync: function _sync() { + let engine = this; + let batchEx = null; + + // Try running sync in batch mode + PlacesUtils.bookmarks.runInBatchMode({ + runBatched: function wrappedSync() { + try { + SyncEngine.prototype._sync.call(engine); } - if (tree.children) { - for (let child of tree.children) { - store._sleep(0); // avoid jank while looping. - yield* walkBookmarksTree(child, tree); - } + catch(ex) { + batchEx = ex; } } - } + }, null); - function* walkBookmarksRoots(tree, rootIDs) { - for (let id of rootIDs) { - let bookmarkRoot = tree.children.find(child => child.id === id); - if (bookmarkRoot === null) { - continue; - } - yield* walkBookmarksTree(bookmarkRoot, tree); - } + // Expose the exception if something inside the batch failed + if (batchEx != null) { + throw batchEx; } + }, - let rootsToWalk = getChangeRootIds(); - - for (let [node, parent] of walkBookmarksRoots(tree, rootsToWalk)) { - let {guid, id, type: placeType} = node; - guid = PlacesSyncUtils.bookmarks.guidToSyncId(guid); + _guidMapFailed: false, + _buildGUIDMap: function _buildGUIDMap() { + let guidMap = {}; + for (let guid in this._store.getAllIDs()) { + // Figure out with which key to store the mapping. let key; - switch (placeType) { - case PlacesUtils.TYPE_X_MOZ_PLACE: - // Bookmark - let query = null; - if (node.annos && node.uri.startsWith("place:")) { - query = node.annos.find(({name}) => - name === PlacesSyncUtils.bookmarks.SMART_BOOKMARKS_ANNO); - } - if (query && query.value) { - key = "q" + query.value; - } else { - key = "b" + node.uri + ":" + (node.title || ""); - } + let id = this._store.idForGUID(guid); + switch (PlacesUtils.bookmarks.getItemType(id)) { + case PlacesUtils.bookmarks.TYPE_BOOKMARK: + + // Smart bookmarks map to their annotation value. + let queryId; + try { + queryId = PlacesUtils.annotations.getItemAnnotation( + id, SMART_BOOKMARKS_ANNO); + } catch(ex) {} + + if (queryId) + key = "q" + queryId; + else + key = "b" + PlacesUtils.bookmarks.getBookmarkURI(id).spec + ":" + + PlacesUtils.bookmarks.getItemTitle(id); break; - case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: - // Folder - key = "f" + (node.title || ""); + case PlacesUtils.bookmarks.TYPE_FOLDER: + key = "f" + PlacesUtils.bookmarks.getItemTitle(id); break; - case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR: - // Separator - key = "s" + node.index; + case PlacesUtils.bookmarks.TYPE_SEPARATOR: + key = "s" + PlacesUtils.bookmarks.getItemIndex(id); break; default: - this._log.error("Unknown place type: '"+placeType+"'"); continue; } - let parentName = parent.title || ""; + // The mapping is on a per parent-folder-name basis. + let parent = PlacesUtils.bookmarks.getFolderIdForItem(id); + if (parent <= 0) + continue; + + let parentName = PlacesUtils.bookmarks.getItemTitle(parent); if (guidMap[parentName] == null) guidMap[parentName] = {}; @@ -381,17 +306,17 @@ BookmarksEngine.prototype = { // hack should get them to dupe correctly. if (item.queryId) { key = "q" + item.queryId; - altKey = "b" + item.bmkUri + ":" + (item.title || ""); + altKey = "b" + item.bmkUri + ":" + item.title; break; } // No queryID? Fall through to the regular bookmark case. case "bookmark": case "microsummary": - key = "b" + item.bmkUri + ":" + (item.title || ""); + key = "b" + item.bmkUri + ":" + item.title; break; case "folder": case "livemark": - key = "f" + (item.title || ""); + key = "f" + item.title; break; case "separator": key = "s" + item.pos; @@ -405,22 +330,21 @@ BookmarksEngine.prototype = { let guidMap = this._guidMap; // Give the GUID if we have the matching pair. - let parentName = item.parentName || ""; - this._log.trace("Finding mapping: " + parentName + ", " + key); - let parent = guidMap[parentName]; - + this._log.trace("Finding mapping: " + item.parentName + ", " + key); + let parent = guidMap[item.parentName]; + if (!parent) { this._log.trace("No parent => no dupe."); return undefined; } - + let dupe = parent[key]; - + if (dupe) { this._log.trace("Mapped dupe: " + dupe); return dupe; } - + if (altKey) { dupe = parent[altKey]; if (dupe) { @@ -428,7 +352,7 @@ BookmarksEngine.prototype = { return dupe; } } - + this._log.trace("No dupe found for key " + key + "/" + altKey + "."); return undefined; }, @@ -437,7 +361,7 @@ BookmarksEngine.prototype = { SyncEngine.prototype._syncStartup.call(this); let cb = Async.makeSpinningCallback(); - Task.spawn(function* () { + Task.spawn(function() { // For first-syncs, make a backup for the user to restore if (this.lastSync == 0) { this._log.debug("Bookmarks backup starting."); @@ -449,7 +373,7 @@ BookmarksEngine.prototype = { // Failure to create a backup is somewhat bad, but probably not bad // enough to prevent syncing of bookmarks - so just log the error and // continue. - this._log.warn("Error while backing up bookmarks, but continuing with sync", ex); + this._log.warn("Got exception backing up bookmarks, but continuing with sync.", ex); cb(); } ); @@ -464,10 +388,8 @@ BookmarksEngine.prototype = { try { guidMap = this._buildGUIDMap(); } catch (ex) { - if (Async.isShutdownException(ex)) { - throw ex; - } - this._log.warn("Error while building GUID map, skipping all other incoming items", ex); + this._log.warn("Got exception building GUID map." + + " Skipping all other incoming items.", ex); throw {code: Engine.prototype.eEngineAbortApplyIncoming, cause: ex}; } @@ -476,71 +398,17 @@ BookmarksEngine.prototype = { }); this._store._childrenToOrder = {}; - this._store.clearPendingDeletions(); - }, - - _deletePending() { - // Delete pending items -- See the comment above BookmarkStore's deletePending - let newlyModified = Async.promiseSpinningly(this._store.deletePending()); - let now = this._tracker._now(); - this._log.debug("Deleted pending items", newlyModified); - for (let modifiedSyncID of newlyModified) { - if (!this._modified.has(modifiedSyncID)) { - this._modified.set(modifiedSyncID, { timestamp: now, deleted: false }); - } - } - }, - - // We avoid reviving folders since reviving them properly would require - // reviving their children as well. Unfortunately, this is the wrong choice - // in the case of a bookmark restore where wipeServer failed -- if the - // server has the folder as deleted, we *would* want to reupload this folder. - // This is mitigated by the fact that we move any undeleted children to the - // grandparent when deleting the parent. - _shouldReviveRemotelyDeletedRecord(item) { - let kind = Async.promiseSpinningly( - PlacesSyncUtils.bookmarks.getKindForSyncId(item.id)); - if (kind === PlacesSyncUtils.bookmarks.KINDS.FOLDER) { - return false; - } - - // In addition to preventing the deletion of this record (handled by the caller), - // we need to mark the parent of this record for uploading next sync, in order - // to ensure its children array is accurate. - let modifiedTimestamp = this._modified.getModifiedTimestamp(item.id); - if (!modifiedTimestamp) { - // We only expect this to be called with items locally modified, so - // something strange is going on - play it safe and don't revive it. - this._log.error("_shouldReviveRemotelyDeletedRecord called on unmodified item: " + item.id); - return false; - } - - let localID = this._store.idForGUID(item.id); - let localParentID = PlacesUtils.bookmarks.getFolderIdForItem(localID); - let localParentSyncID = this._store.GUIDForId(localParentID); - - this._log.trace(`Reviving item "${item.id}" and marking parent ${localParentSyncID} as modified.`); - - if (!this._modified.has(localParentSyncID)) { - this._modified.set(localParentSyncID, { - timestamp: modifiedTimestamp, - deleted: false - }); - } - return true }, _processIncoming: function (newitems) { try { SyncEngine.prototype._processIncoming.call(this, newitems); } finally { - try { - this._deletePending(); - } finally { - // Reorder children. - this._store._orderChildren(); - delete this._store._childrenToOrder; - } + // Reorder children. + this._tracker.ignoreAll = true; + this._store._orderChildren(); + this._tracker.ignoreAll = false; + delete this._store._childrenToOrder; } }, @@ -575,154 +443,16 @@ BookmarksEngine.prototype = { } let mapped = this._mapDupe(item); this._log.debug(item.id + " mapped to " + mapped); - // We must return a string, not an object, and the entries in the GUIDMap - // are created via "new String()" making them an object. - return mapped ? mapped.toString() : mapped; - }, - - pullAllChanges() { - return new BookmarksChangeset(this._store.getAllIDs()); - }, - - pullNewChanges() { - let modifiedGUIDs = this._getModifiedGUIDs(); - if (!modifiedGUIDs.length) { - return new BookmarksChangeset(this._tracker.changedIDs); - } - - // We don't use `PlacesUtils.promiseDBConnection` here because - // `getChangedIDs` might be called while we're in a batch, meaning we - // won't see any changes until the batch finishes and the transaction - // commits. - let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase) - .DBConnection; - - // Filter out tags, organizer queries, and other descendants that we're - // not tracking. We chunk `modifiedGUIDs` because SQLite limits the number - // of bound parameters per query. - for (let startIndex = 0; - startIndex < modifiedGUIDs.length; - startIndex += SQLITE_MAX_VARIABLE_NUMBER) { - - let chunkLength = Math.min(SQLITE_MAX_VARIABLE_NUMBER, - modifiedGUIDs.length - startIndex); - - let query = ` - WITH RECURSIVE - modifiedGuids(guid) AS ( - VALUES ${new Array(chunkLength).fill("(?)").join(", ")} - ), - syncedItems(id) AS ( - VALUES ${getChangeRootIds().map(id => `(${id})`).join(", ")} - UNION ALL - SELECT b.id - FROM moz_bookmarks b - JOIN syncedItems s ON b.parent = s.id - ) - SELECT b.guid - FROM modifiedGuids m - JOIN moz_bookmarks b ON b.guid = m.guid - LEFT JOIN syncedItems s ON b.id = s.id - WHERE s.id IS NULL - `; - - let statement = db.createAsyncStatement(query); - try { - for (let i = 0; i < chunkLength; i++) { - statement.bindByIndex(i, modifiedGUIDs[startIndex + i]); - } - let results = Async.querySpinningly(statement, ["guid"]); - for (let { guid } of results) { - let syncID = PlacesSyncUtils.bookmarks.guidToSyncId(guid); - this._tracker.removeChangedID(syncID); - } - } finally { - statement.finalize(); - } - } - - return new BookmarksChangeset(this._tracker.changedIDs); - }, - - // Returns an array of Places GUIDs for all changed items. Ignores deletions, - // which won't exist in the DB and shouldn't be removed from the tracker. - _getModifiedGUIDs() { - let guids = []; - for (let syncID in this._tracker.changedIDs) { - if (this._tracker.changedIDs[syncID].deleted === true) { - // The `===` check also filters out old persisted timestamps, - // which won't have a `deleted` property. - continue; - } - let guid = PlacesSyncUtils.bookmarks.syncIdToGuid(syncID); - guids.push(guid); - } - return guids; - }, - - // Called when _findDupe returns a dupe item and the engine has decided to - // switch the existing item to the new incoming item. - _switchItemToDupe(localDupeGUID, incomingItem) { - // We unconditionally change the item's ID in case the engine knows of - // an item but doesn't expose it through itemExists. If the API - // contract were stronger, this could be changed. - this._log.debug("Switching local ID to incoming: " + localDupeGUID + " -> " + - incomingItem.id); - this._store.changeItemID(localDupeGUID, incomingItem.id); - - // And mark the parent as being modified. Given we de-dupe based on the - // parent *name* it's possible the item having its GUID changed has a - // different parent from the incoming record. - // So we need to find the GUID of the local parent. - let now = this._tracker._now(); - let localID = this._store.idForGUID(incomingItem.id); - let localParentID = PlacesUtils.bookmarks.getFolderIdForItem(localID); - let localParentGUID = this._store.GUIDForId(localParentID); - this._modified.set(localParentGUID, { modified: now, deleted: false }); - - // And we also add the parent as reflected in the incoming record as the - // de-dupe process might have used an existing item in a different folder. - // But only if the parent exists, otherwise we will upload a deleted item - // when it might actually be valid, just unknown to us. Note that this - // scenario will still leave us with inconsistent client and server states; - // the incoming record on the server references a parent that isn't the - // actual parent locally - see bug 1297955. - if (localParentGUID != incomingItem.parentid) { - let remoteParentID = this._store.idForGUID(incomingItem.parentid); - if (remoteParentID > 0) { - // The parent specified in the record does exist, so we are going to - // attempt a move when we come to applying the record. Mark the parent - // as being modified so we will later upload it with the new child - // reference. - this._modified.set(incomingItem.parentid, { modified: now, deleted: false }); - } else { - // We aren't going to do a move as we don't have the parent (yet?). - // When applying the record we will add our special PARENT_ANNO - // annotation, so if it arrives in the future (either this Sync or a - // later one) it will be reparented. - this._log.debug(`Incoming duplicate item ${incomingItem.id} specifies ` + - `non-existing parent ${incomingItem.parentid}`); - } - } - - // The local, duplicate ID is always deleted on the server - but for - // bookmarks it is a logical delete. - // Simply adding this (now non-existing) ID to the tracker is enough. - this._modified.set(localDupeGUID, { modified: now, deleted: true }); - }, - getValidator() { - return new BookmarkValidator(); + return mapped; } }; function BookmarksStore(name, engine) { Store.call(this, name, engine); - this._foldersToDelete = new Set(); - this._atomsToDelete = new Set(); + // Explicitly nullify our references to our cached services so we don't leak Svc.Obs.add("places-shutdown", function() { - for (let query in this._stmts) { - let stmt = this._stmts[query]; + for each (let [query, stmt] in Iterator(this._stmts)) { stmt.finalize(); } this._stmts = {}; @@ -732,12 +462,70 @@ BookmarksStore.prototype = { __proto__: Store.prototype, itemExists: function BStore_itemExists(id) { - return this.idForGUID(id) > 0; + return this.idForGUID(id, true) > 0; }, + + /* + * If the record is a tag query, rewrite it to refer to the local tag ID. + * + * Otherwise, just return. + */ + preprocessTagQuery: function preprocessTagQuery(record) { + if (record.type != "query" || + record.bmkUri == null || + !record.folderName) + return; + + // Yes, this works without chopping off the "place:" prefix. + let uri = record.bmkUri + let queriesRef = {}; + let queryCountRef = {}; + let optionsRef = {}; + PlacesUtils.history.queryStringToQueries(uri, queriesRef, queryCountRef, + optionsRef); + + // We only process tag URIs. + if (optionsRef.value.resultType != optionsRef.value.RESULTS_AS_TAG_CONTENTS) + return; + + // Tag something to ensure that the tag exists. + let tag = record.folderName; + let dummyURI = Utils.makeURI("about:weave#BStore_preprocess"); + PlacesUtils.tagging.tagURI(dummyURI, [tag]); + + // Look for the id of the tag, which might just have been added. + let tags = this._getNode(PlacesUtils.tagsFolderId); + if (!(tags instanceof Ci.nsINavHistoryQueryResultNode)) { + this._log.debug("tags isn't an nsINavHistoryQueryResultNode; aborting."); + return; + } + tags.containerOpen = true; + try { + for (let i = 0; i < tags.childCount; i++) { + let child = tags.getChild(i); + if (child.title == tag) { + // Found the tag, so fix up the query to use the right id. + this._log.debug("Tag query folder: " + tag + " = " + child.itemId); + + this._log.trace("Replacing folders in: " + uri); + for each (let q in queriesRef.value) + q.setFolders([child.itemId], 1); + + record.bmkUri = PlacesUtils.history.queriesToQueryString( + queriesRef.value, queryCountRef.value, optionsRef.value); + return; + } + } + } + finally { + tags.containerOpen = false; + } + }, + applyIncoming: function BStore_applyIncoming(record) { this._log.debug("Applying record " + record.id); - let isSpecial = PlacesSyncUtils.bookmarks.ROOTS.includes(record.id); + let isSpecial = record.id in kSpecialIds; if (record.deleted) { if (isSpecial) { @@ -765,217 +553,548 @@ BookmarksStore.prototype = { return; } + // Preprocess the record before doing the normal apply. + this.preprocessTagQuery(record); + // Figure out the local id of the parent GUID if available let parentGUID = record.parentid; if (!parentGUID) { throw "Record " + record.id + " has invalid parentid: " + parentGUID; } - this._log.debug("Remote parent is " + parentGUID); + this._log.debug("Local parent is " + parentGUID); + + let parentId = this.idForGUID(parentGUID); + if (parentId > 0) { + // Save the parent id for modifying the bookmark later + record._parent = parentId; + record._orphan = false; + this._log.debug("Record " + record.id + " is not an orphan."); + } else { + this._log.trace("Record " + record.id + + " is an orphan: could not find parent " + parentGUID); + record._orphan = true; + } // Do the normal processing of incoming records Store.prototype.applyIncoming.call(this, record); - if (record.type == "folder" && record.children) { - this._childrenToOrder[record.id] = record.children; + // Do some post-processing if we have an item + let itemId = this.idForGUID(record.id); + if (itemId > 0) { + // Move any children that are looking for this folder as a parent + if (record.type == "folder") { + this._reparentOrphans(itemId); + // Reorder children later + if (record.children) + this._childrenToOrder[record.id] = record.children; + } + + // Create an annotation to remember that it needs reparenting. + if (record._orphan) { + PlacesUtils.annotations.setItemAnnotation( + itemId, PARENT_ANNO, parentGUID, 0, + PlacesUtils.annotations.EXPIRE_NEVER); + } + } + }, + + /** + * Find all ids of items that have a given value for an annotation + */ + _findAnnoItems: function BStore__findAnnoItems(anno, val) { + return PlacesUtils.annotations.getItemsWithAnnotation(anno, {}) + .filter(function(id) { + return PlacesUtils.annotations.getItemAnnotation(id, anno) == val; + }); + }, + + /** + * For the provided parent item, attach its children to it + */ + _reparentOrphans: function _reparentOrphans(parentId) { + // Find orphans and reunite with this folder parent + let parentGUID = this.GUIDForId(parentId); + let orphans = this._findAnnoItems(PARENT_ANNO, parentGUID); + + this._log.debug("Reparenting orphans " + orphans + " to " + parentId); + orphans.forEach(function(orphan) { + // Move the orphan to the parent and drop the missing parent annotation + if (this._reparentItem(orphan, parentId)) { + PlacesUtils.annotations.removeItemAnnotation(orphan, PARENT_ANNO); + } + }, this); + }, + + _reparentItem: function _reparentItem(itemId, parentId) { + this._log.trace("Attempting to move item " + itemId + " to new parent " + + parentId); + try { + if (parentId > 0) { + PlacesUtils.bookmarks.moveItem(itemId, parentId, + PlacesUtils.bookmarks.DEFAULT_INDEX); + return true; + } + } catch(ex) { + this._log.debug("Failed to reparent item. ", ex); + } + return false; + }, + + // Turn a record's nsINavBookmarksService constant and other attributes into + // a granular type for comparison. + _recordType: function _recordType(itemId) { + let bms = PlacesUtils.bookmarks; + let type = bms.getItemType(itemId); + + switch (type) { + case bms.TYPE_FOLDER: + if (PlacesUtils.annotations + .itemHasAnnotation(itemId, PlacesUtils.LMANNO_FEEDURI)) { + return "livemark"; + } + return "folder"; + + case bms.TYPE_BOOKMARK: + let bmkUri = bms.getBookmarkURI(itemId).spec; + if (bmkUri.indexOf("place:") == 0) { + return "query"; + } + return "bookmark"; + + case bms.TYPE_SEPARATOR: + return "separator"; + + default: + return null; } }, create: function BStore_create(record) { - let info = record.toSyncBookmark(); - // This can throw if we're inserting an invalid or incomplete bookmark. - // That's fine; the exception will be caught by `applyIncomingBatch` - // without aborting further processing. - let item = Async.promiseSpinningly(PlacesSyncUtils.bookmarks.insert(info)); - if (item) { - this._log.debug(`Created ${item.kind} ${item.syncId} under ${ - item.parentSyncId}`, item); + // Default to unfiled if we don't have the parent yet. + + // Valid parent IDs are all positive integers. Other values -- undefined, + // null, -1 -- all compare false for > 0, so this catches them all. We + // don't just use <= without the !, because undefined and null compare + // false for that, too! + if (!(record._parent > 0)) { + this._log.debug("Parent is " + record._parent + "; reparenting to unfiled."); + record._parent = kSpecialIds.unfiled; + } + + let newId; + switch (record.type) { + case "bookmark": + case "query": + case "microsummary": { + let uri = Utils.makeURI(record.bmkUri); + newId = PlacesUtils.bookmarks.insertBookmark( + record._parent, uri, PlacesUtils.bookmarks.DEFAULT_INDEX, record.title); + this._log.debug("created bookmark " + newId + " under " + record._parent + + " as " + record.title + " " + record.bmkUri); + + // Smart bookmark annotations are strings. + if (record.queryId) { + PlacesUtils.annotations.setItemAnnotation( + newId, SMART_BOOKMARKS_ANNO, record.queryId, 0, + PlacesUtils.annotations.EXPIRE_NEVER); + } + + if (Array.isArray(record.tags)) { + this._tagURI(uri, record.tags); + } + PlacesUtils.bookmarks.setKeywordForBookmark(newId, record.keyword); + if (record.description) { + PlacesUtils.annotations.setItemAnnotation( + newId, DESCRIPTION_ANNO, record.description, 0, + PlacesUtils.annotations.EXPIRE_NEVER); + } + + if (record.loadInSidebar) { + PlacesUtils.annotations.setItemAnnotation( + newId, SIDEBAR_ANNO, true, 0, + PlacesUtils.annotations.EXPIRE_NEVER); + } + + } break; + case "folder": + newId = PlacesUtils.bookmarks.createFolder( + record._parent, record.title, PlacesUtils.bookmarks.DEFAULT_INDEX); + this._log.debug("created folder " + newId + " under " + record._parent + + " as " + record.title); + + if (record.description) { + PlacesUtils.annotations.setItemAnnotation( + newId, DESCRIPTION_ANNO, record.description, 0, + PlacesUtils.annotations.EXPIRE_NEVER); + } + + // record.children will be dealt with in _orderChildren. + break; + case "livemark": + let siteURI = null; + if (!record.feedUri) { + this._log.debug("No feed URI: skipping livemark record " + record.id); + return; + } + if (PlacesUtils.annotations + .itemHasAnnotation(record._parent, PlacesUtils.LMANNO_FEEDURI)) { + this._log.debug("Invalid parent: skipping livemark record " + record.id); + return; + } + + if (record.siteUri != null) + siteURI = Utils.makeURI(record.siteUri); + + // Until this engine can handle asynchronous error reporting, we need to + // detect errors on creation synchronously. + let spinningCb = Async.makeSpinningCallback(); + + let livemarkObj = {title: record.title, + parentId: record._parent, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + feedURI: Utils.makeURI(record.feedUri), + siteURI: siteURI, + guid: record.id}; + PlacesUtils.livemarks.addLivemark(livemarkObj).then( + aLivemark => { spinningCb(null, [Components.results.NS_OK, aLivemark]) }, + () => { spinningCb(null, [Components.results.NS_ERROR_UNEXPECTED, aLivemark]) } + ); + + let [status, livemark] = spinningCb.wait(); + if (!Components.isSuccessCode(status)) { + throw status; + } + + this._log.debug("Created livemark " + livemark.id + " under " + + livemark.parentId + " as " + livemark.title + + ", " + livemark.siteURI.spec + ", " + + livemark.feedURI.spec + ", GUID " + + livemark.guid); + break; + case "separator": + newId = PlacesUtils.bookmarks.insertSeparator( + record._parent, PlacesUtils.bookmarks.DEFAULT_INDEX); + this._log.debug("created separator " + newId + " under " + record._parent); + break; + case "item": + this._log.debug(" -> got a generic places item.. do nothing?"); + return; + default: + this._log.error("_create: Unknown item type: " + record.type); + return; + } + + if (newId) { + // Livemarks can set the GUID through the API, so there's no need to + // do that here. + this._log.trace("Setting GUID of new item " + newId + " to " + record.id); + this._setGUID(newId, record.id); + } + }, + + // Factored out of `remove` to avoid redundant DB queries when the Places ID + // is already known. + removeById: function removeById(itemId, guid) { + let type = PlacesUtils.bookmarks.getItemType(itemId); + + switch (type) { + case PlacesUtils.bookmarks.TYPE_BOOKMARK: + this._log.debug(" -> removing bookmark " + guid); + PlacesUtils.bookmarks.removeItem(itemId); + break; + case PlacesUtils.bookmarks.TYPE_FOLDER: + this._log.debug(" -> removing folder " + guid); + PlacesUtils.bookmarks.removeItem(itemId); + break; + case PlacesUtils.bookmarks.TYPE_SEPARATOR: + this._log.debug(" -> removing separator " + guid); + PlacesUtils.bookmarks.removeItem(itemId); + break; + default: + this._log.error("remove: Unknown item type: " + type); + break; } }, remove: function BStore_remove(record) { - if (PlacesSyncUtils.bookmarks.isRootSyncID(record.id)) { + if (kSpecialIds.isSpecialGUID(record.id)) { this._log.warn("Refusing to remove special folder " + record.id); return; } - let recordKind = Async.promiseSpinningly( - PlacesSyncUtils.bookmarks.getKindForSyncId(record.id)); - let isFolder = recordKind === PlacesSyncUtils.bookmarks.KINDS.FOLDER; - this._log.trace(`Buffering removal of item "${record.id}" of type "${recordKind}".`); - if (isFolder) { - this._foldersToDelete.add(record.id); - } else { - this._atomsToDelete.add(record.id); + + let itemId = this.idForGUID(record.id); + if (itemId <= 0) { + this._log.debug("Item " + record.id + " already removed"); + return; } + this.removeById(itemId, record.id); }, - update: function BStore_update(record) { - let info = record.toSyncBookmark(); - let item = Async.promiseSpinningly(PlacesSyncUtils.bookmarks.update(info)); - if (item) { - this._log.debug(`Updated ${item.kind} ${item.syncId} under ${ - item.parentSyncId}`, item); - } + _taggableTypes: ["bookmark", "microsummary", "query"], + isTaggable: function isTaggable(recordType) { + return this._taggableTypes.indexOf(recordType) != -1; }, - _orderChildren: function _orderChildren() { - let promises = Object.keys(this._childrenToOrder).map(syncID => { - let children = this._childrenToOrder[syncID]; - return PlacesSyncUtils.bookmarks.order(syncID, children).catch(ex => { - this._log.debug(`Could not order children for ${syncID}`, ex); - }); - }); - Async.promiseSpinningly(Promise.all(promises)); - }, - - // There's some complexity here around pending deletions. Our goals: - // - // - Don't delete any bookmarks a user has created but not explicitly deleted - // (This includes any bookmark that was not a child of the folder at the - // time the deletion was recorded, and also bookmarks restored from a backup). - // - Don't undelete any bookmark without ensuring the server structure - // includes it (see `BookmarkEngine.prototype._shouldReviveRemotelyDeletedRecord`) - // - // This leads the following approach: - // - // - Additions, moves, and updates are processed before deletions. - // - To do this, all deletion operations are buffered during a sync. Folders - // we plan on deleting have their sync id's stored in `this._foldersToDelete`, - // and non-folders we plan on deleting have their sync id's stored in - // `this._atomsToDelete`. - // - The exception to this is the moves that occur to fix the order of bookmark - // children, which are performed after we process deletions. - // - Non-folders are deleted before folder deletions, so that when we process - // folder deletions we know the correct state. - // - Remote deletions always win for folders, but do not result in recursive - // deletion of children. This is a hack because we're not able to distinguish - // between value changes and structural changes to folders, and we don't even - // have the old server record to compare to. See `BookmarkEngine`'s - // `_shouldReviveRemotelyDeletedRecord` method. - // - When a folder is deleted, its remaining children are moved in order to - // their closest living ancestor. If this is interrupted (unlikely, but - // possible given that we don't perform this operation in a transaction), - // we revive the folder. - // - Remote deletions can lose for non-folders, but only until we handle - // bookmark restores correctly (removing stale state from the server -- this - // is to say, if bug 1230011 is fixed, we should never revive bookmarks). - - deletePending: Task.async(function* deletePending() { - yield this._deletePendingAtoms(); - let guidsToUpdate = yield this._deletePendingFolders(); - this.clearPendingDeletions(); - return guidsToUpdate; - }), - - clearPendingDeletions() { - this._foldersToDelete.clear(); - this._atomsToDelete.clear(); - }, - - _deleteAtom: Task.async(function* _deleteAtom(syncID) { - try { - let info = yield PlacesSyncUtils.bookmarks.remove(syncID, { - preventRemovalOfNonEmptyFolders: true - }); - this._log.trace(`Removed item ${syncID} with type ${info.type}`); - } catch (ex) { - // Likely already removed. - this._log.trace(`Error removing ${syncID}`, ex); + update: function BStore_update(record) { + let itemId = this.idForGUID(record.id); + + if (itemId <= 0) { + this._log.debug("Skipping update for unknown item: " + record.id); + return; } - }), - - _deletePendingAtoms() { - return Promise.all( - [...this._atomsToDelete.values()] - .map(syncID => this._deleteAtom(syncID))); - }, - - // Returns an array of sync ids that need updates. - _deletePendingFolders: Task.async(function* _deletePendingFolders() { - // To avoid data loss, we don't want to just delete the folder outright, - // so we buffer folder deletions and process them at the end (now). - // - // 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 needUpdate = new Set(); - for (let syncId of this._foldersToDelete) { - let childSyncIds = yield PlacesSyncUtils.bookmarks.fetchChildSyncIds(syncId); - if (!childSyncIds.length) { - // No children -- just delete the folder. - yield this._deleteAtom(syncId) - continue; - } - // We could avoid some redundant work here by finding the nearest - // grandparent who isn't present in `this._toDelete`... - let grandparentSyncId = this.GUIDForId( - PlacesUtils.bookmarks.getFolderIdForItem( - this.idForGUID(PlacesSyncUtils.bookmarks.syncIdToGuid(syncId)))); + // Two items are the same type if they have the same ItemType in Places, + // and also share some key characteristics (e.g., both being livemarks). + // We figure this out by examining the item to find the equivalent granular + // (string) type. + // If they're not the same type, we can't just update attributes. Delete + // then recreate the record instead. + let localItemType = this._recordType(itemId); + let remoteRecordType = record.type; + this._log.trace("Local type: " + localItemType + ". " + + "Remote type: " + remoteRecordType + "."); + + if (localItemType != remoteRecordType) { + this._log.debug("Local record and remote record differ in type. " + + "Deleting and recreating."); + this.removeById(itemId, record.id); + this.create(record); + return; + } - this._log.trace(`Moving ${childSyncIds.length} children of "${syncId}" to ` + - `grandparent "${grandparentSyncId}" before deletion.`); + this._log.trace("Updating " + record.id + " (" + itemId + ")"); - // Move children out of the parent and into the grandparent - yield Promise.all(childSyncIds.map(child => PlacesSyncUtils.bookmarks.update({ - syncId: child, - parentSyncId: grandparentSyncId - }))); + // Move the bookmark to a new parent or new position if necessary + if (record._parent > 0 && + PlacesUtils.bookmarks.getFolderIdForItem(itemId) != record._parent) { + this._reparentItem(itemId, record._parent); + } - // Delete the (now empty) parent - try { - yield PlacesSyncUtils.bookmarks.remove(syncId, { - preventRemovalOfNonEmptyFolders: true - }); - } 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). - needUpdate.add(syncId); + for (let [key, val] in Iterator(record.cleartext)) { + switch (key) { + case "title": + PlacesUtils.bookmarks.setItemTitle(itemId, val); + break; + case "bmkUri": + PlacesUtils.bookmarks.changeBookmarkURI(itemId, Utils.makeURI(val)); + break; + case "tags": + if (Array.isArray(val)) { + if (this.isTaggable(remoteRecordType)) { + this._tagID(itemId, val); + } else { + this._log.debug("Remote record type is invalid for tags: " + remoteRecordType); + } + } + break; + case "keyword": + PlacesUtils.bookmarks.setKeywordForBookmark(itemId, val); + break; + case "description": + if (val) { + PlacesUtils.annotations.setItemAnnotation( + itemId, DESCRIPTION_ANNO, val, 0, + PlacesUtils.annotations.EXPIRE_NEVER); + } else { + PlacesUtils.annotations.removeItemAnnotation(itemId, DESCRIPTION_ANNO); + } + break; + case "loadInSidebar": + if (val) { + PlacesUtils.annotations.setItemAnnotation( + itemId, SIDEBAR_ANNO, true, 0, + PlacesUtils.annotations.EXPIRE_NEVER); + } else { + PlacesUtils.annotations.removeItemAnnotation(itemId, SIDEBAR_ANNO); + } + break; + case "queryId": + PlacesUtils.annotations.setItemAnnotation( + itemId, SMART_BOOKMARKS_ANNO, val, 0, + PlacesUtils.annotations.EXPIRE_NEVER); + break; } + } + }, - // Add children (for parentid) and grandparent (for children list) to set - // of records needing an update, *unless* they're marked for deletion. - if (!this._foldersToDelete.has(grandparentSyncId)) { - needUpdate.add(grandparentSyncId); - } - for (let childSyncId of childSyncIds) { - if (!this._foldersToDelete.has(childSyncId)) { - needUpdate.add(childSyncId); + _orderChildren: function _orderChildren() { + for (let [guid, children] in Iterator(this._childrenToOrder)) { + // Reorder children according to the GUID list. Gracefully deal + // with missing items, e.g. locally deleted. + let delta = 0; + let parent = null; + for (let idx = 0; idx < children.length; idx++) { + let itemid = this.idForGUID(children[idx]); + if (itemid == -1) { + delta += 1; + this._log.trace("Could not locate record " + children[idx]); + continue; + } + try { + // This code path could be optimized by caching the parent earlier. + // Doing so should take in count any edge case due to reparenting + // or parent invalidations though. + if (!parent) { + parent = PlacesUtils.bookmarks.getFolderIdForItem(itemid); + } + PlacesUtils.bookmarks.moveItem(itemid, parent, idx - delta); + } catch (ex) { + this._log.debug("Could not move item " + children[idx] + ": " + ex); } } } - return [...needUpdate]; - }), + }, changeItemID: function BStore_changeItemID(oldID, newID) { this._log.debug("Changing GUID " + oldID + " to " + newID); - Async.promiseSpinningly(PlacesSyncUtils.bookmarks.changeGuid(oldID, newID)); + // Make sure there's an item to change GUIDs + let itemId = this.idForGUID(oldID); + if (itemId <= 0) + return; + + this._setGUID(itemId, newID); + }, + + _getNode: function BStore__getNode(folder) { + let query = PlacesUtils.history.getNewQuery(); + query.setFolders([folder], 1); + return PlacesUtils.history.executeQuery( + query, PlacesUtils.history.getNewQueryOptions()).root; + }, + + _getTags: function BStore__getTags(uri) { + try { + if (typeof(uri) == "string") + uri = Utils.makeURI(uri); + } catch(e) { + this._log.warn("Could not parse URI \"" + uri + "\": " + e); + } + return PlacesUtils.tagging.getTagsForURI(uri, {}); + }, + + _getDescription: function BStore__getDescription(id) { + try { + return PlacesUtils.annotations.getItemAnnotation(id, DESCRIPTION_ANNO); + } catch (e) { + return null; + } + }, + + _isLoadInSidebar: function BStore__isLoadInSidebar(id) { + return PlacesUtils.annotations.itemHasAnnotation(id, SIDEBAR_ANNO); + }, + + get _childGUIDsStm() { + return this._getStmt( + "SELECT id AS item_id, guid " + + "FROM moz_bookmarks " + + "WHERE parent = :parent " + + "ORDER BY position"); + }, + _childGUIDsCols: ["item_id", "guid"], + + _getChildGUIDsForId: function _getChildGUIDsForId(itemid) { + let stmt = this._childGUIDsStm; + stmt.params.parent = itemid; + let rows = Async.querySpinningly(stmt, this._childGUIDsCols); + return rows.map(function (row) { + if (row.guid) { + return row.guid; + } + // A GUID hasn't been assigned to this item yet, do this now. + return this.GUIDForId(row.item_id); + }, this); }, // Create a record starting from the weave id (places guid) createRecord: function createRecord(id, collection) { - let item = Async.promiseSpinningly(PlacesSyncUtils.bookmarks.fetch(id)); - if (!item) { // deleted item - let record = new PlacesItem(collection, id); + let placeId = this.idForGUID(id); + let record; + if (placeId <= 0) { // deleted item + record = new PlacesItem(collection, id); record.deleted = true; return record; } - let recordObj = getTypeObject(item.kind); - if (!recordObj) { - this._log.warn("Unknown item type, cannot serialize: " + item.kind); - recordObj = PlacesItem; + let parent = PlacesUtils.bookmarks.getFolderIdForItem(placeId); + switch (PlacesUtils.bookmarks.getItemType(placeId)) { + case PlacesUtils.bookmarks.TYPE_BOOKMARK: + let bmkUri = PlacesUtils.bookmarks.getBookmarkURI(placeId).spec; + if (bmkUri.indexOf("place:") == 0) { + record = new BookmarkQuery(collection, id); + + // Get the actual tag name instead of the local itemId + let folder = bmkUri.match(/[:&]folder=(\d+)/); + try { + // There might not be the tag yet when creating on a new client + if (folder != null) { + folder = folder[1]; + record.folderName = PlacesUtils.bookmarks.getItemTitle(folder); + this._log.trace("query id: " + folder + " = " + record.folderName); + } + } + catch(ex) {} + + // Persist the Smart Bookmark anno, if found. + try { + let anno = PlacesUtils.annotations.getItemAnnotation(placeId, SMART_BOOKMARKS_ANNO); + if (anno != null) { + this._log.trace("query anno: " + SMART_BOOKMARKS_ANNO + + " = " + anno); + record.queryId = anno; + } + } + catch(ex) {} + } + else { + record = new Bookmark(collection, id); + } + record.title = PlacesUtils.bookmarks.getItemTitle(placeId); + + record.parentName = PlacesUtils.bookmarks.getItemTitle(parent); + record.bmkUri = bmkUri; + record.tags = this._getTags(record.bmkUri); + record.keyword = PlacesUtils.bookmarks.getKeywordForBookmark(placeId); + record.description = this._getDescription(placeId); + record.loadInSidebar = this._isLoadInSidebar(placeId); + break; + + case PlacesUtils.bookmarks.TYPE_FOLDER: + if (PlacesUtils.annotations + .itemHasAnnotation(placeId, PlacesUtils.LMANNO_FEEDURI)) { + record = new Livemark(collection, id); + let as = PlacesUtils.annotations; + record.feedUri = as.getItemAnnotation(placeId, PlacesUtils.LMANNO_FEEDURI); + try { + record.siteUri = as.getItemAnnotation(placeId, PlacesUtils.LMANNO_SITEURI); + } catch (ex) {} + } else { + record = new BookmarkFolder(collection, id); + } + + if (parent > 0) + record.parentName = PlacesUtils.bookmarks.getItemTitle(parent); + record.title = PlacesUtils.bookmarks.getItemTitle(placeId); + record.description = this._getDescription(placeId); + record.children = this._getChildGUIDsForId(placeId); + break; + + case PlacesUtils.bookmarks.TYPE_SEPARATOR: + record = new BookmarkSeparator(collection, id); + if (parent > 0) + record.parentName = PlacesUtils.bookmarks.getItemTitle(parent); + // Create a positioning identifier for the separator, used by _mapDupe + record.pos = PlacesUtils.bookmarks.getItemIndex(placeId); + break; + + default: + record = new PlacesItem(collection, id); + this._log.warn("Unknown item type, cannot serialize: " + + PlacesUtils.bookmarks.getItemType(placeId)); } - let record = new recordObj(collection, id); - record.fromSyncBookmark(item); + record.parentid = this.GUIDForId(parent); record.sortindex = this._calculateIndex(record); return record; @@ -997,22 +1116,84 @@ BookmarksStore.prototype = { return this._getStmt( "SELECT frecency " + "FROM moz_places " + - "WHERE url_hash = hash(:url) AND url = :url " + + "WHERE url = :url " + "LIMIT 1"); }, _frecencyCols: ["frecency"], + get _setGUIDStm() { + return this._getStmt( + "UPDATE moz_bookmarks " + + "SET guid = :guid " + + "WHERE id = :item_id"); + }, + + // Some helper functions to handle GUIDs + _setGUID: function _setGUID(id, guid) { + if (!guid) + guid = Utils.makeGUID(); + + let stmt = this._setGUIDStm; + stmt.params.guid = guid; + stmt.params.item_id = id; + Async.querySpinningly(stmt); + return guid; + }, + + get _guidForIdStm() { + return this._getStmt( + "SELECT guid " + + "FROM moz_bookmarks " + + "WHERE id = :item_id"); + }, + _guidForIdCols: ["guid"], + GUIDForId: function GUIDForId(id) { - let guid = Async.promiseSpinningly(PlacesUtils.promiseItemGuid(id)); - return PlacesSyncUtils.bookmarks.guidToSyncId(guid); + let special = kSpecialIds.specialGUIDForId(id); + if (special) + return special; + + let stmt = this._guidForIdStm; + stmt.params.item_id = id; + + // Use the existing GUID if it exists + let result = Async.querySpinningly(stmt, this._guidForIdCols)[0]; + if (result && result.guid) + return result.guid; + + // Give the uri a GUID if it doesn't have one + return this._setGUID(id); }, - idForGUID: function idForGUID(guid) { - // guid might be a String object rather than a string. - guid = PlacesSyncUtils.bookmarks.syncIdToGuid(guid.toString()); + get _idForGUIDStm() { + return this._getStmt( + "SELECT id AS item_id " + + "FROM moz_bookmarks " + + "WHERE guid = :guid"); + }, + _idForGUIDCols: ["item_id"], + + // noCreate is provided as an optional argument to prevent the creation of + // non-existent special records, such as "mobile". + idForGUID: function idForGUID(guid, noCreate) { + if (kSpecialIds.isSpecialGUID(guid)) + return kSpecialIds.specialIdForGUID(guid, !noCreate); - return Async.promiseSpinningly(PlacesUtils.promiseItemId(guid).catch( - ex => -1)); + let stmt = this._idForGUIDStm; + // guid might be a String object rather than a string. + stmt.params.guid = guid.toString(); + + let results = Async.querySpinningly(stmt, this._idForGUIDCols); + this._log.trace("Number of rows matching GUID " + guid + ": " + + results.length); + + // Here's the one we care about: the first. + let result = results[0]; + + if (!result) + return -1; + + return result.item_id; }, _calculateIndex: function _calculateIndex(record) { @@ -1037,48 +1218,106 @@ BookmarksStore.prototype = { return index; }, - getAllIDs: function BStore_getAllIDs() { - let items = {}; - - let query = ` - WITH RECURSIVE - changeRootContents(id) AS ( - VALUES ${getChangeRootIds().map(id => `(${id})`).join(", ")} - UNION ALL - SELECT b.id - FROM moz_bookmarks b - JOIN changeRootContents c ON b.parent = c.id - ) - SELECT guid - FROM changeRootContents - JOIN moz_bookmarks USING (id) - `; - - let statement = this._getStmt(query); - let results = Async.querySpinningly(statement, ["guid"]); - for (let { guid } of results) { - let syncID = PlacesSyncUtils.bookmarks.guidToSyncId(guid); - items[syncID] = { modified: 0, deleted: false }; + _getChildren: function BStore_getChildren(guid, items) { + let node = guid; // the recursion case + if (typeof(node) == "string") { // callers will give us the guid as the first arg + let nodeID = this.idForGUID(guid, true); + if (!nodeID) { + this._log.debug("No node for GUID " + guid + "; returning no children."); + return items; + } + node = this._getNode(nodeID); + } + + if (node.type == node.RESULT_TYPE_FOLDER) { + node.QueryInterface(Ci.nsINavHistoryQueryResultNode); + node.containerOpen = true; + try { + // Remember all the children GUIDs and recursively get more + for (let i = 0; i < node.childCount; i++) { + let child = node.getChild(i); + items[this.GUIDForId(child.itemId)] = true; + this._getChildren(child, items); + } + } + finally { + node.containerOpen = false; + } } return items; }, + /** + * Associates the URI of the item with the provided ID with the + * provided array of tags. + * If the provided ID does not identify an item with a URI, + * returns immediately. + */ + _tagID: function _tagID(itemID, tags) { + if (!itemID || !tags) { + return; + } + + try { + let u = PlacesUtils.bookmarks.getBookmarkURI(itemID); + this._tagURI(u, tags); + } catch (e) { + this._log.warn("Got exception fetching URI for " + itemID + ": not tagging. ", e); + + // I guess it doesn't have a URI. Don't try to tag it. + return; + } + }, + + /** + * Associate the provided URI with the provided array of tags. + * If the provided URI is falsy, returns immediately. + */ + _tagURI: function _tagURI(bookmarkURI, tags) { + if (!bookmarkURI || !tags) { + return; + } + + // Filter out any null/undefined/empty tags. + tags = tags.filter(t => t); + + // Temporarily tag a dummy URI to preserve tag ids when untagging. + let dummyURI = Utils.makeURI("about:weave#BStore_tagURI"); + PlacesUtils.tagging.tagURI(dummyURI, tags); + PlacesUtils.tagging.untagURI(bookmarkURI, null); + PlacesUtils.tagging.tagURI(bookmarkURI, tags); + PlacesUtils.tagging.untagURI(dummyURI, null); + }, + + getAllIDs: function BStore_getAllIDs() { + let items = {"menu": true, + "toolbar": true}; + for each (let guid in kSpecialIds.guids) { + if (guid != "places" && guid != "tags") + this._getChildren(guid, items); + } + return items; + }, + wipe: function BStore_wipe() { - this.clearPendingDeletions(); - Async.promiseSpinningly(Task.spawn(function* () { + let cb = Async.makeSpinningCallback(); + Task.spawn(function() { // Save a backup before clearing out all bookmarks. yield PlacesBackups.create(null, true); - yield PlacesUtils.bookmarks.eraseEverything({ - source: SOURCE_SYNC, - }); - })); + for each (let guid in kSpecialIds.guids) + if (guid != "places") { + let id = kSpecialIds.specialIdForGUID(guid); + if (id) + PlacesUtils.bookmarks.removeFolderChildren(id); + } + cb(); + }); + cb.wait(); } }; function BookmarksTracker(name, engine) { - this._batchDepth = 0; - this._batchSawScoreIncrement = false; Tracker.call(this, name, engine); Svc.Obs.add("places-shutdown", this); @@ -1086,16 +1325,6 @@ function BookmarksTracker(name, engine) { BookmarksTracker.prototype = { __proto__: Tracker.prototype, - //`_ignore` checks the change source for each observer notification, so we - // don't want to let the engine ignore all changes during a sync. - get ignoreAll() { - return false; - }, - - // Define an empty setter so that the engine doesn't throw a `TypeError` - // setting a read-only property. - set ignoreAll(value) {}, - startTracking: function() { PlacesUtils.bookmarks.addObserver(this, true); Svc.Obs.add("bookmarks-restore-begin", this); @@ -1116,9 +1345,11 @@ BookmarksTracker.prototype = { switch (topic) { case "bookmarks-restore-begin": this._log.debug("Ignoring changes from importing bookmarks."); + this.ignoreAll = true; break; case "bookmarks-restore-success": this._log.debug("Tracking all items on successful import."); + this.ignoreAll = false; this._log.debug("Restore succeeded: wiping server and other clients."); this.engine.service.resetClient([this.name]); @@ -1127,6 +1358,7 @@ BookmarksTracker.prototype = { break; case "bookmarks-restore-failed": this._log.debug("Tracking all items on failed import."); + this.ignoreAll = false; break; } }, @@ -1137,68 +1369,73 @@ BookmarksTracker.prototype = { Ci.nsISupportsWeakReference ]), - addChangedID(id, change) { - if (!id) { - this._log.warn("Attempted to add undefined ID to tracker"); - return false; - } - if (this._ignored.includes(id)) { - return false; - } - let shouldSaveChange = false; - let currentChange = this.changedIDs[id]; - if (currentChange) { - if (typeof currentChange == "number") { - // Allow raw timestamps for backward-compatibility with persisted - // changed IDs. The new format uses tuples to track deleted items. - shouldSaveChange = currentChange < change.modified; - } else { - shouldSaveChange = currentChange.modified < change.modified || - currentChange.deleted != change.deleted; - } - } else { - shouldSaveChange = true; - } - if (shouldSaveChange) { - this._saveChangedID(id, change); - } - return true; - }, - /** * Add a bookmark GUID to be uploaded and bump up the sync score. * - * @param itemId - * The Places item ID of the bookmark to upload. - * @param guid - * The Places GUID of the bookmark to upload. - * @param isTombstone - * Whether we're uploading a tombstone for a removed bookmark. + * @param itemGuid + * GUID of the bookmark to upload. */ - _add: function BMT__add(itemId, guid, isTombstone = false) { - let syncID = PlacesSyncUtils.bookmarks.guidToSyncId(guid); - let info = { modified: Date.now() / 1000, deleted: isTombstone }; - if (this.addChangedID(syncID, info)) { + _add: function BMT__add(itemId, guid) { + guid = kSpecialIds.specialGUIDForId(itemId) || guid; + if (this.addChangedID(guid)) this._upScore(); - } }, - /* Every add/remove/change will trigger a sync for MULTI_DEVICE (except in - a batch operation, where we do it at the end of the batch) */ + /* Every add/remove/change will trigger a sync for MULTI_DEVICE. */ _upScore: function BMT__upScore() { - if (this._batchDepth == 0) { - this.score += SCORE_INCREMENT_XLARGE; - } else { - this._batchSawScoreIncrement = true; + this.score += SCORE_INCREMENT_XLARGE; + }, + + /** + * Determine if a change should be ignored. + * + * @param itemId + * Item under consideration to ignore + * @param folder (optional) + * Folder of the item being changed + */ + _ignore: function BMT__ignore(itemId, folder, guid) { + // Ignore unconditionally if the engine tells us to. + if (this.ignoreAll) + return true; + + // Get the folder id if we weren't given one. + if (folder == null) { + try { + folder = PlacesUtils.bookmarks.getFolderIdForItem(itemId); + } catch (ex) { + this._log.debug("getFolderIdForItem(" + itemId + + ") threw; calling _ensureMobileQuery."); + // I'm guessing that gFIFI can throw, and perhaps that's why + // _ensureMobileQuery is here at all. Try not to call it. + this._ensureMobileQuery(); + folder = PlacesUtils.bookmarks.getFolderIdForItem(itemId); + } + } + + // Ignore changes to tags (folders under the tags folder). + let tags = kSpecialIds.tags; + if (folder == tags) + return true; + + // Ignore tag items (the actual instance of a tag for a bookmark). + if (PlacesUtils.bookmarks.getFolderIdForItem(folder) == tags) + return true; + + // Make sure to remove items that have the exclude annotation. + if (PlacesUtils.annotations.itemHasAnnotation(itemId, EXCLUDEBACKUP_ANNO)) { + this.removeChangedID(guid); + return true; } + + return false; }, onItemAdded: function BMT_onItemAdded(itemId, folder, index, itemType, uri, title, dateAdded, - guid, parentGuid, source) { - if (IGNORED_SOURCES.includes(source)) { + guid, parentGuid) { + if (this._ignore(itemId, folder, guid)) return; - } this._log.trace("onItemAdded: " + itemId); this._add(itemId, guid); @@ -1206,51 +1443,13 @@ BookmarksTracker.prototype = { }, onItemRemoved: function (itemId, parentId, index, type, uri, - guid, parentGuid, source) { - if (IGNORED_SOURCES.includes(source)) { - return; - } - - // Ignore changes to tags (folders under the tags folder). - if (parentId == PlacesUtils.tagsFolderId) { - return; - } - - let grandParentId = -1; - try { - grandParentId = PlacesUtils.bookmarks.getFolderIdForItem(parentId); - } catch (ex) { - // `getFolderIdForItem` can throw if the item no longer exists, such as - // when we've removed a subtree using `removeFolderChildren`. - return; - } - - // Ignore tag items (the actual instance of a tag for a bookmark). - if (grandParentId == PlacesUtils.tagsFolderId) { + guid, parentGuid) { + if (this._ignore(itemId, parentId, guid)) { return; } - /** - * The above checks are incomplete: we can still write tombstones for - * items that we don't track, and upload extraneous roots. - * - * Consider the left pane root: it's a child of the Places root, and has - * children and grandchildren. `PlacesUIUtils` can create, delete, and - * recreate it as needed. We can't determine ancestors when the root or its - * children are deleted, because they've already been removed from the - * database when `onItemRemoved` is called. Likewise, we can't check their - * "exclude from backup" annos, because they've *also* been removed. - * - * So, we end up writing tombstones for the left pane queries and left - * pane root. For good measure, we'll also upload the Places root, because - * it's the parent of the left pane root. - * - * As a workaround, we can track the parent GUID and reconstruct the item's - * ancestry at sync time. This is complicated, and the previous behavior was - * already wrong, so we'll wait for bug 1258127 to fix this generally. - */ this._log.trace("onItemRemoved: " + itemId); - this._add(itemId, guid, /* isTombstone */ true); + this._add(itemId, guid); this._add(parentId, parentGuid); }, @@ -1265,40 +1464,32 @@ BookmarksTracker.prototype = { if (all.length == 0) return; + // Disable handling of notifications while changing the mobile query + this.ignoreAll = true; + let mobile = find(MOBILE_ANNO); - let queryURI = Utils.makeURI("place:folder=" + PlacesUtils.mobileFolderId); - let title = PlacesBundle.GetStringFromName("MobileBookmarksFolderTitle"); + let queryURI = Utils.makeURI("place:folder=" + kSpecialIds.mobile); + let title = Str.sync.get("mobile.label"); // Don't add OR remove the mobile bookmarks if there's nothing. - if (PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.mobileFolderId, 0) == -1) { + if (PlacesUtils.bookmarks.getIdForItemAt(kSpecialIds.mobile, 0) == -1) { if (mobile.length != 0) - PlacesUtils.bookmarks.removeItem(mobile[0], SOURCE_SYNC); + PlacesUtils.bookmarks.removeItem(mobile[0]); } // Add the mobile bookmarks query if it doesn't exist else if (mobile.length == 0) { - let query = PlacesUtils.bookmarks.insertBookmark(all[0], queryURI, -1, title, /* guid */ null, SOURCE_SYNC); + let query = PlacesUtils.bookmarks.insertBookmark(all[0], queryURI, -1, title); PlacesUtils.annotations.setItemAnnotation(query, ORGANIZERQUERY_ANNO, MOBILE_ANNO, 0, - PlacesUtils.annotations.EXPIRE_NEVER, SOURCE_SYNC); - PlacesUtils.annotations.setItemAnnotation(query, PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO, 1, 0, - PlacesUtils.annotations.EXPIRE_NEVER, SOURCE_SYNC); + PlacesUtils.annotations.EXPIRE_NEVER); + PlacesUtils.annotations.setItemAnnotation(query, EXCLUDEBACKUP_ANNO, 1, 0, + PlacesUtils.annotations.EXPIRE_NEVER); } - // Make sure the existing query URL and title are correct - else { - if (!PlacesUtils.bookmarks.getBookmarkURI(mobile[0]).equals(queryURI)) { - PlacesUtils.bookmarks.changeBookmarkURI(mobile[0], queryURI, - SOURCE_SYNC); - } - let queryTitle = PlacesUtils.bookmarks.getItemTitle(mobile[0]); - if (queryTitle != title) { - PlacesUtils.bookmarks.setItemTitle(mobile[0], title, SOURCE_SYNC); - } - let rootTitle = - PlacesUtils.bookmarks.getItemTitle(PlacesUtils.mobileFolderId); - if (rootTitle != title) { - PlacesUtils.bookmarks.setItemTitle(PlacesUtils.mobileFolderId, title, - SOURCE_SYNC); - } + // Make sure the existing title is correct + else if (PlacesUtils.bookmarks.getItemTitle(mobile[0]) != title) { + PlacesUtils.bookmarks.setItemTitle(mobile[0], title); } + + this.ignoreAll = false; }, // This method is oddly structured, but the idea is to return as quickly as @@ -1306,11 +1497,10 @@ BookmarksTracker.prototype = { // *each change*. onItemChanged: function BMT_onItemChanged(itemId, property, isAnno, value, lastModified, itemType, parentId, - guid, parentGuid, oldValue, - source) { - if (IGNORED_SOURCES.includes(source)) { + guid, parentGuid) { + // Quicker checks first. + if (this.ignoreAll) return; - } if (isAnno && (ANNOS_TO_TRACK.indexOf(property) == -1)) // Ignore annotations except for the ones that we sync. @@ -1320,6 +1510,9 @@ BookmarksTracker.prototype = { if (property == "favicon") return; + if (this._ignore(itemId, parentId, guid)) + return; + this._log.trace("onItemChanged: " + itemId + (", " + property + (isAnno? " (anno)" : "")) + (value ? (" = \"" + value + "\"") : "")); @@ -1328,11 +1521,9 @@ BookmarksTracker.prototype = { onItemMoved: function BMT_onItemMoved(itemId, oldParent, oldIndex, newParent, newIndex, itemType, - guid, oldParentGuid, newParentGuid, - source) { - if (IGNORED_SOURCES.includes(source)) { + guid, oldParentGuid, newParentGuid) { + if (this._ignore(itemId, newParent, guid)) return; - } this._log.trace("onItemMoved: " + itemId); this._add(oldParent, oldParentGuid); @@ -1342,37 +1533,10 @@ BookmarksTracker.prototype = { } // Remove any position annotations now that the user moved the item - PlacesUtils.annotations.removeItemAnnotation(itemId, - PlacesSyncUtils.bookmarks.SYNC_PARENT_ANNO, SOURCE_SYNC); + PlacesUtils.annotations.removeItemAnnotation(itemId, PARENT_ANNO); }, - onBeginUpdateBatch: function () { - ++this._batchDepth; - }, - onEndUpdateBatch: function () { - if (--this._batchDepth === 0 && this._batchSawScoreIncrement) { - this.score += SCORE_INCREMENT_XLARGE; - this._batchSawScoreIncrement = false; - } - }, + onBeginUpdateBatch: function () {}, + onEndUpdateBatch: function () {}, onItemVisited: function () {} }; - -// Returns an array of root IDs to recursively query for synced bookmarks. -// Items in other roots, including tags and organizer queries, will be -// ignored. -function getChangeRootIds() { - return [ - PlacesUtils.bookmarksMenuFolderId, - PlacesUtils.toolbarFolderId, - PlacesUtils.unfiledBookmarksFolderId, - PlacesUtils.mobileFolderId, - ]; -} - -class BookmarksChangeset extends Changeset { - getModifiedTimestamp(id) { - let change = this.changes[id]; - return change ? change.modified : Number.NaN; - } -} diff --git a/services/sync/modules/engines/clients.js b/services/sync/modules/engines/clients.js index 3dd679570..6c8e37a7b 100644 --- a/services/sync/modules/engines/clients.js +++ b/services/sync/modules/engines/clients.js @@ -2,24 +2,6 @@ * 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/. */ -/** - * How does the clients engine work? - * - * - We use 2 files - commands.json and commands-syncing.json. - * - * - At sync upload time, we attempt a rename of commands.json to - * commands-syncing.json, and ignore errors (helps for crash during sync!). - * - We load commands-syncing.json and stash the contents in - * _currentlySyncingCommands which lives for the duration of the upload process. - * - We use _currentlySyncingCommands to build the outgoing records - * - Immediately after successful upload, we delete commands-syncing.json from - * disk (and clear _currentlySyncingCommands). We reconcile our local records - * with what we just wrote in the server, and add failed IDs commands - * back in commands.json - * - Any time we need to "save" a command for future syncs, we load - * commands.json, update it, and write it back out. - */ - this.EXPORTED_SYMBOLS = [ "ClientEngine", "ClientsRec" @@ -27,32 +9,17 @@ this.EXPORTED_SYMBOLS = [ var {classes: Cc, interfaces: Ci, utils: Cu} = Components; -Cu.import("resource://services-common/async.js"); Cu.import("resource://services-common/stringbundle.js"); Cu.import("resource://services-sync/constants.js"); Cu.import("resource://services-sync/engines.js"); Cu.import("resource://services-sync/record.js"); -Cu.import("resource://services-sync/resource.js"); Cu.import("resource://services-sync/util.js"); -Cu.import("resource://gre/modules/Services.jsm"); - -XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts", - "resource://gre/modules/FxAccounts.jsm"); const CLIENTS_TTL = 1814400; // 21 days const CLIENTS_TTL_REFRESH = 604800; // 7 days -const STALE_CLIENT_REMOTE_AGE = 604800; // 7 days const SUPPORTED_PROTOCOL_VERSIONS = ["1.1", "1.5"]; -function hasDupeCommand(commands, action) { - if (!commands) { - return false; - } - return commands.some(other => other.command == action.command && - Utils.deepEquals(other.args, action.args)); -} - this.ClientsRec = function ClientsRec(collection, id) { CryptoWrapper.call(this, collection, id); } @@ -66,27 +33,23 @@ Utils.deferGetSet(ClientsRec, "cleartext", ["name", "type", "commands", "version", "protocols", - "formfactor", "os", "appPackage", "application", "device", - "fxaDeviceId"]); + "formfactor", "os", "appPackage", "application", "device"]); this.ClientEngine = function ClientEngine(service) { SyncEngine.call(this, "Clients", service); - // Reset the last sync timestamp on every startup so that we fetch all clients - this.resetLastSync(); + // Reset the client on every startup so that we fetch recent clients + this._resetClient(); } ClientEngine.prototype = { __proto__: SyncEngine.prototype, _storeObj: ClientStore, _recordObj: ClientsRec, _trackerObj: ClientsTracker, - allowSkippedRecord: false, // Always sync client data as it controls other sync behavior - get enabled() { - return true; - }, + get enabled() true, get lastRecordUpload() { return Svc.Prefs.get(this.name + ".lastRecordUpload", 0); @@ -95,20 +58,10 @@ ClientEngine.prototype = { Svc.Prefs.set(this.name + ".lastRecordUpload", Math.floor(value)); }, - get remoteClients() { - // return all non-stale clients for external consumption. - return Object.values(this._store._remoteClients).filter(v => !v.stale); - }, - - remoteClientExists(id) { - let client = this._store._remoteClients[id]; - return !!(client && !client.stale); - }, - // Aggregate some stats on the composition of clients on this account get stats() { let stats = { - hasMobile: this.localType == DEVICE_TYPE_MOBILE, + hasMobile: this.localType == "mobile", names: [this.localName], numClients: 1, }; @@ -156,9 +109,7 @@ ClientEngine.prototype = { let localID = Svc.Prefs.get("client.GUID", ""); return localID == "" ? this.localID = Utils.makeGUID() : localID; }, - set localID(value) { - Svc.Prefs.set("client.GUID", value); - }, + set localID(value) Svc.Prefs.set("client.GUID", value), get brandName() { let brand = new StringBundle("chrome://branding/locale/brand.properties"); @@ -166,97 +117,23 @@ ClientEngine.prototype = { }, get localName() { - let name = Utils.getDeviceName(); - // If `getDeviceName` returns the default name, set the pref. FxA registers - // the device before syncing, so we don't need to update the registration - // in this case. - Svc.Prefs.set("client.name", name); - return name; - }, - set localName(value) { - Svc.Prefs.set("client.name", value); - // Update the registration in the background. - fxAccounts.updateDeviceRegistration().catch(error => { - this._log.warn("failed to update fxa device registration", error); - }); - }, + let localName = Svc.Prefs.get("client.name", ""); + if (localName != "") + return localName; - get localType() { - return Utils.getDeviceType(); - }, - set localType(value) { - Svc.Prefs.set("client.type", value); + return this.localName = Utils.getDefaultDeviceName(); }, + set localName(value) Svc.Prefs.set("client.name", value), - getClientName(id) { - if (id == this.localID) { - return this.localName; - } - let client = this._store._remoteClients[id]; - return client ? client.name : ""; - }, - - getClientFxaDeviceId(id) { - if (this._store._remoteClients[id]) { - return this._store._remoteClients[id].fxaDeviceId; - } - return null; - }, + get localType() Svc.Prefs.get("client.type", "desktop"), + set localType(value) Svc.Prefs.set("client.type", value), isMobile: function isMobile(id) { if (this._store._remoteClients[id]) - return this._store._remoteClients[id].type == DEVICE_TYPE_MOBILE; + return this._store._remoteClients[id].type == "mobile"; return false; }, - _readCommands() { - let cb = Async.makeSpinningCallback(); - Utils.jsonLoad("commands", this, commands => cb(null, commands)); - return cb.wait() || {}; - }, - - /** - * Low level function, do not use directly (use _addClientCommand instead). - */ - _saveCommands(commands) { - let cb = Async.makeSpinningCallback(); - Utils.jsonSave("commands", this, commands, error => { - if (error) { - this._log.error("Failed to save JSON outgoing commands", error); - } - cb(); - }); - cb.wait(); - }, - - _prepareCommandsForUpload() { - let cb = Async.makeSpinningCallback(); - Utils.jsonMove("commands", "commands-syncing", this).catch(() => {}) // Ignore errors - .then(() => { - Utils.jsonLoad("commands-syncing", this, commands => cb(null, commands)); - }); - return cb.wait() || {}; - }, - - _deleteUploadedCommands() { - delete this._currentlySyncingCommands; - Async.promiseSpinningly( - Utils.jsonRemove("commands-syncing", this).catch(err => { - this._log.error("Failed to delete syncing-commands file", err); - }) - ); - }, - - _addClientCommand(clientId, command) { - const allCommands = this._readCommands(); - const clientCommands = allCommands[clientId] || []; - if (hasDupeCommand(clientCommands, command)) { - return; - } - allCommands[clientId] = clientCommands.concat(command); - this._saveCommands(allCommands); - }, - _syncStartup: function _syncStartup() { // Reupload new client record periodically. if (Date.now() / 1000 - this.lastRecordUpload > CLIENTS_TTL_REFRESH) { @@ -266,157 +143,9 @@ ClientEngine.prototype = { SyncEngine.prototype._syncStartup.call(this); }, - _processIncoming() { - // Fetch all records from the server. - this.lastSync = 0; - this._incomingClients = {}; - try { - SyncEngine.prototype._processIncoming.call(this); - // Since clients are synced unconditionally, any records in the local store - // that don't exist on the server must be for disconnected clients. Remove - // them, so that we don't upload records with commands for clients that will - // never see them. We also do this to filter out stale clients from the - // tabs collection, since showing their list of tabs is confusing. - for (let id in this._store._remoteClients) { - if (!this._incomingClients[id]) { - this._log.info(`Removing local state for deleted client ${id}`); - this._removeRemoteClient(id); - } - } - // Bug 1264498: Mobile clients don't remove themselves from the clients - // collection when the user disconnects Sync, so we mark as stale clients - // with the same name that haven't synced in over a week. - // (Note we can't simply delete them, or we re-apply them next sync - see - // bug 1287687) - delete this._incomingClients[this.localID]; - let names = new Set([this.localName]); - for (let id in this._incomingClients) { - let record = this._store._remoteClients[id]; - if (!names.has(record.name)) { - names.add(record.name); - continue; - } - let remoteAge = AsyncResource.serverTime - this._incomingClients[id]; - if (remoteAge > STALE_CLIENT_REMOTE_AGE) { - this._log.info(`Hiding stale client ${id} with age ${remoteAge}`); - record.stale = true; - } - } - } finally { - this._incomingClients = null; - } - }, - - _uploadOutgoing() { - this._currentlySyncingCommands = this._prepareCommandsForUpload(); - const clientWithPendingCommands = Object.keys(this._currentlySyncingCommands); - for (let clientId of clientWithPendingCommands) { - if (this._store._remoteClients[clientId] || this.localID == clientId) { - this._modified.set(clientId, 0); - } - } - SyncEngine.prototype._uploadOutgoing.call(this); - }, - - _onRecordsWritten(succeeded, failed) { - // Reconcile the status of the local records with what we just wrote on the - // server - for (let id of succeeded) { - const commandChanges = this._currentlySyncingCommands[id]; - if (id == this.localID) { - if (this.localCommands) { - this.localCommands = this.localCommands.filter(command => !hasDupeCommand(commandChanges, command)); - } - } else { - const clientRecord = this._store._remoteClients[id]; - if (!commandChanges || !clientRecord) { - // should be impossible, else we wouldn't have been writing it. - this._log.warn("No command/No record changes for a client we uploaded"); - continue; - } - // fixup the client record, so our copy of _remoteClients matches what we uploaded. - clientRecord.commands = this._store.createRecord(id); - // we could do better and pass the reference to the record we just uploaded, - // but this will do for now - } - } - - // Re-add failed commands - for (let id of failed) { - const commandChanges = this._currentlySyncingCommands[id]; - if (!commandChanges) { - continue; - } - this._addClientCommand(id, commandChanges); - } - - this._deleteUploadedCommands(); - - // Notify other devices that their own client collection changed - const idsToNotify = succeeded.reduce((acc, id) => { - if (id == this.localID) { - return acc; - } - const fxaDeviceId = this.getClientFxaDeviceId(id); - return fxaDeviceId ? acc.concat(fxaDeviceId) : acc; - }, []); - if (idsToNotify.length > 0) { - this._notifyCollectionChanged(idsToNotify); - } - }, - - _notifyCollectionChanged(ids) { - const message = { - version: 1, - command: "sync:collection_changed", - data: { - collections: ["clients"] - } - }; - fxAccounts.notifyDevices(ids, message, NOTIFY_TAB_SENT_TTL_SECS); - }, - - _syncFinish() { - // Record histograms for our device types, and also write them to a pref - // so non-histogram telemetry (eg, UITelemetry) has easy access to them. - for (let [deviceType, count] of this.deviceTypes) { - let hid; - let prefName = this.name + ".devices."; - switch (deviceType) { - case "desktop": - hid = "WEAVE_DEVICE_COUNT_DESKTOP"; - prefName += "desktop"; - break; - case "mobile": - hid = "WEAVE_DEVICE_COUNT_MOBILE"; - prefName += "mobile"; - break; - default: - this._log.warn(`Unexpected deviceType "${deviceType}" recording device telemetry.`); - continue; - } - Services.telemetry.getHistogramById(hid).add(count); - Svc.Prefs.set(prefName, count); - } - SyncEngine.prototype._syncFinish.call(this); - }, - - _reconcile: function _reconcile(item) { - // Every incoming record is reconciled, so we use this to track the - // contents of the collection on the server. - this._incomingClients[item.id] = item.modified; - - if (!this._store.itemExists(item.id)) { - return true; - } - // Clients are synced unconditionally, so we'll always have new records. - // Unfortunately, this will cause the scheduler to use the immediate sync - // interval for the multi-device case, instead of the active interval. We - // work around this by updating the record during reconciliation, and - // returning false to indicate that the record doesn't need to be applied - // later. - this._store.update(item); - return false; + // Always process incoming items because they might have commands + _reconcile: function _reconcile() { + return true; }, // Treat reset the same as wiping for locally cached clients @@ -426,13 +155,7 @@ ClientEngine.prototype = { _wipeClient: function _wipeClient() { SyncEngine.prototype._resetClient.call(this); - delete this.localCommands; this._store.wipe(); - const logRemoveError = err => this._log.warn("Could not delete json file", err); - Async.promiseSpinningly( - Utils.jsonRemove("commands", this).catch(logRemoveError) - .then(Utils.jsonRemove("commands-syncing", this).catch(logRemoveError)) - ); }, removeClientData: function removeClientData() { @@ -471,6 +194,14 @@ ClientEngine.prototype = { }, /** + * Remove any commands for the local client and mark it for upload. + */ + clearCommands: function clearCommands() { + delete this.localCommands; + this._tracker.addChangedID(this.localID); + }, + + /** * Sends a command+args pair to a specific client. * * @param command Command string @@ -484,17 +215,30 @@ ClientEngine.prototype = { if (!client) { throw new Error("Unknown remote client ID: '" + clientId + "'."); } - if (client.stale) { - throw new Error("Stale remote client ID: '" + clientId + "'."); - } + + // notDupe compares two commands and returns if they are not equal. + let notDupe = function(other) { + return other.command != command || !Utils.deepEquals(other.args, args); + }; let action = { command: command, args: args, }; + if (!client.commands) { + client.commands = [action]; + } + // Add the new action if there are no duplicates. + else if (client.commands.every(notDupe)) { + client.commands.push(action); + } + // It must be a dupe. Skip. + else { + return; + } + this._log.trace("Client " + clientId + " got a new action: " + [command, args]); - this._addClientCommand(clientId, action); this._tracker.addChangedID(clientId); }, @@ -505,17 +249,13 @@ ClientEngine.prototype = { */ processIncomingCommands: function processIncomingCommands() { return this._notify("clients:process-commands", "", function() { - if (!this.localCommands) { - return true; - } + let commands = this.localCommands; - const clearedCommands = this._readCommands()[this.localID]; - const commands = this.localCommands.filter(command => !hasDupeCommand(clearedCommands, command)); + // Immediately clear out the commands as we've got them locally. + this.clearCommands(); - let URIsToDisplay = []; // Process each command in order. - for (let rawCommand of commands) { - let {command, args} = rawCommand; + for each (let {command, args} in commands) { this._log.debug("Processing command: " + command + "(" + args + ")"); let engines = [args[0]]; @@ -536,20 +276,12 @@ ClientEngine.prototype = { this.service.logout(); return false; case "displayURI": - let [uri, clientId, title] = args; - URIsToDisplay.push({ uri, clientId, title }); + this._handleDisplayURI.apply(this, args); break; default: this._log.debug("Received an unknown command: " + command); break; } - // Add the command to the "cleared" commands list - this._addClientCommand(this.localID, rawCommand) - } - this._tracker.addChangedID(this.localID); - - if (URIsToDisplay.length) { - this._handleDisplayURIs(URIsToDisplay); } return true; @@ -588,10 +320,8 @@ ClientEngine.prototype = { if (clientId) { this._sendCommandToClient(command, args, clientId); } else { - for (let [id, record] of Object.entries(this._store._remoteClients)) { - if (!record.stale) { - this._sendCommandToClient(command, args, id); - } + for (let id in this._store._remoteClients) { + this._sendCommandToClient(command, args, id); } } }, @@ -622,11 +352,11 @@ ClientEngine.prototype = { }, /** - * Handle a bunch of received 'displayURI' commands. + * Handle a single received 'displayURI' command. * - * Interested parties should observe the "weave:engine:clients:display-uris" - * topic. The callback will receive an array as the subject parameter - * containing objects with the following keys: + * Interested parties should observe the "weave:engine:clients:display-uri" + * topic. The callback will receive an object as the subject parameter with + * the following keys: * * uri URI (string) that is requested for display. * clientId ID of client that sent the command. @@ -634,24 +364,21 @@ ClientEngine.prototype = { * * The 'data' parameter to the callback will not be defined. * - * @param uris - * An array containing URI objects to display - * @param uris[].uri + * @param uri * String URI that was received - * @param uris[].clientId + * @param clientId * ID of client that sent URI - * @param uris[].title + * @param title * String title of page that URI corresponds to. Older clients may not * send this. */ - _handleDisplayURIs: function _handleDisplayURIs(uris) { - Svc.Obs.notify("weave:engine:clients:display-uris", uris); - }, + _handleDisplayURI: function _handleDisplayURI(uri, clientId, title) { + this._log.info("Received a URI for display: " + uri + " (" + title + + ") from " + clientId); - _removeRemoteClient(id) { - delete this._store._remoteClients[id]; - this._tracker.removeChangedID(id); - }, + let subject = {uri: uri, client: clientId, title: title}; + Svc.Obs.notify("weave:engine:clients:display-uri", subject); + } }; function ClientStore(name, engine) { @@ -660,48 +387,29 @@ function ClientStore(name, engine) { ClientStore.prototype = { __proto__: Store.prototype, - _remoteClients: {}, - create(record) { - this.update(record); + this.update(record) }, update: function update(record) { - if (record.id == this.engine.localID) { - // Only grab commands from the server; local name/type always wins + // Only grab commands from the server; local name/type always wins + if (record.id == this.engine.localID) this.engine.localCommands = record.commands; - } else { + else this._remoteClients[record.id] = record.cleartext; - } }, createRecord: function createRecord(id, collection) { let record = new ClientsRec(collection, id); - const commandsChanges = this.engine._currentlySyncingCommands ? - this.engine._currentlySyncingCommands[id] : - []; - // Package the individual components into a record for the local client if (id == this.engine.localID) { - let cb = Async.makeSpinningCallback(); - fxAccounts.getDeviceId().then(id => cb(null, id), cb); - try { - record.fxaDeviceId = cb.wait(); - } catch(error) { - this._log.warn("failed to get fxa device id", error); - } record.name = this.engine.localName; record.type = this.engine.localType; + record.commands = this.engine.localCommands; record.version = Services.appinfo.version; record.protocols = SUPPORTED_PROTOCOL_VERSIONS; - // Substract the commands we recorded that we've already executed - if (commandsChanges && commandsChanges.length && - this.engine.localCommands && this.engine.localCommands.length) { - record.commands = this.engine.localCommands.filter(command => !hasDupeCommand(commandsChanges, command)); - } - // Optional fields. record.os = Services.appinfo.OS; // "Darwin" record.appPackage = Services.appinfo.ID; @@ -712,20 +420,6 @@ ClientStore.prototype = { // record.formfactor = ""; // Bug 1100722 } else { record.cleartext = this._remoteClients[id]; - - // Add the commands we have to send - if (commandsChanges && commandsChanges.length) { - const recordCommands = record.cleartext.commands || []; - const newCommands = commandsChanges.filter(command => !hasDupeCommand(recordCommands, command)); - record.cleartext.commands = recordCommands.concat(newCommands); - } - - if (record.cleartext.stale) { - // It's almost certainly a logic error for us to upload a record we - // consider stale, so make log noise, but still remove the flag. - this._log.error(`Preparing to upload record ${id} that we consider stale`); - delete record.cleartext.stale; - } } return record; @@ -768,7 +462,7 @@ ClientsTracker.prototype = { break; case "weave:engine:stop-tracking": if (this._enabled) { - Svc.Prefs.ignore("client.name", this); + Svc.Prefs.ignore("clients.name", this); this._enabled = false; } break; diff --git a/services/sync/modules/engines/forms.js b/services/sync/modules/engines/forms.js index 43f79d4f7..11dd8d976 100644 --- a/services/sync/modules/engines/forms.js +++ b/services/sync/modules/engines/forms.js @@ -2,7 +2,7 @@ * 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/. */ -this.EXPORTED_SYMBOLS = ['FormEngine', 'FormRec', 'FormValidator']; +this.EXPORTED_SYMBOLS = ['FormEngine', 'FormRec']; var Cc = Components.classes; var Ci = Components.interfaces; @@ -14,10 +14,9 @@ Cu.import("resource://services-sync/record.js"); Cu.import("resource://services-common/async.js"); Cu.import("resource://services-sync/util.js"); Cu.import("resource://services-sync/constants.js"); -Cu.import("resource://services-sync/collection_validator.js"); Cu.import("resource://gre/modules/Log.jsm"); -const FORMS_TTL = 3 * 365 * 24 * 60 * 60; // Three years in seconds. +const FORMS_TTL = 5184000; // 60 days this.FormRec = function FormRec(collection, id) { CryptoWrapper.call(this, collection, id); @@ -37,24 +36,20 @@ var FormWrapper = { _getEntryCols: ["fieldname", "value"], _guidCols: ["guid"], - _promiseSearch: function(terms, searchData) { - return new Promise(resolve => { - let results = []; - let callbacks = { - handleResult(result) { - results.push(result); - }, - handleCompletion(reason) { - resolve(results); - } - }; - Svc.FormHistory.search(terms, searchData, callbacks); - }) - }, - // Do a "sync" search by spinning the event loop until it completes. _searchSpinningly: function(terms, searchData) { - return Async.promiseSpinningly(this._promiseSearch(terms, searchData)); + let results = []; + let cb = Async.makeSpinningCallback(); + let callbacks = { + handleResult: function(result) { + results.push(result); + }, + handleCompletion: function(reason) { + cb(null, results); + } + }; + Svc.FormHistory.search(terms, searchData, callbacks); + return cb.wait(); }, _updateSpinningly: function(changes) { @@ -114,9 +109,7 @@ FormEngine.prototype = { syncPriority: 6, - get prefName() { - return "history"; - }, + get prefName() "history", _findDupe: function _findDupe(item) { return FormWrapper.getGUID(item.name, item.value); @@ -235,6 +228,7 @@ FormTracker.prototype = { if (this.ignoreAll) { return; } + switch (topic) { case "satchel-storage-changed": if (data == "formhistory-add" || data == "formhistory-remove") { @@ -250,56 +244,3 @@ FormTracker.prototype = { this.score += SCORE_INCREMENT_MEDIUM; }, }; - - -class FormsProblemData extends CollectionProblemData { - getSummary() { - // We don't support syncing deleted form data, so "clientMissing" isn't a problem - return super.getSummary().filter(entry => - entry.name !== "clientMissing"); - } -} - -class FormValidator extends CollectionValidator { - constructor() { - super("forms", "id", ["name", "value"]); - } - - emptyProblemData() { - return new FormsProblemData(); - } - - getClientItems() { - return FormWrapper._promiseSearch(["guid", "fieldname", "value"], {}); - } - - normalizeClientItem(item) { - return { - id: item.guid, - guid: item.guid, - name: item.fieldname, - fieldname: item.fieldname, - value: item.value, - original: item, - }; - } - - normalizeServerItem(item) { - let res = Object.assign({ - guid: item.id, - fieldname: item.name, - original: item, - }, item); - // Missing `name` or `value` causes the getGUID call to throw - if (item.name !== undefined && item.value !== undefined) { - let guid = FormWrapper.getGUID(item.name, item.value); - if (guid) { - res.guid = guid; - res.id = guid; - res.duped = true; - } - } - - return res; - } -}
\ No newline at end of file diff --git a/services/sync/modules/engines/history.js b/services/sync/modules/engines/history.js index 307d484c1..e7f53766f 100644 --- a/services/sync/modules/engines/history.js +++ b/services/sync/modules/engines/history.js @@ -44,25 +44,6 @@ HistoryEngine.prototype = { applyIncomingBatchSize: HISTORY_STORE_BATCH_SIZE, syncPriority: 7, - - _processIncoming: function (newitems) { - // We want to notify history observers that a batch operation is underway - // so they don't do lots of work for each incoming record. - let observers = PlacesUtils.history.getObservers(); - function notifyHistoryObservers(notification) { - for (let observer of observers) { - try { - observer[notification](); - } catch (ex) { } - } - } - notifyHistoryObservers("onBeginUpdateBatch"); - try { - return SyncEngine.prototype._processIncoming.call(this, newitems); - } finally { - notifyHistoryObservers("onEndUpdateBatch"); - } - }, }; function HistoryStore(name, engine) { @@ -105,7 +86,7 @@ HistoryStore.prototype = { return this._getStmt( "UPDATE moz_places " + "SET guid = :guid " + - "WHERE url_hash = hash(:page_url) AND url = :page_url"); + "WHERE url = :page_url"); }, // Some helper functions to handle GUIDs @@ -127,7 +108,7 @@ HistoryStore.prototype = { return this._getStmt( "SELECT guid " + "FROM moz_places " + - "WHERE url_hash = hash(:page_url) AND url = :page_url"); + "WHERE url = :page_url"); }, _guidCols: ["guid"], @@ -146,12 +127,12 @@ HistoryStore.prototype = { }, get _visitStm() { - return this._getStmt(`/* do not warn (bug 599936) */ - SELECT visit_type type, visit_date date - FROM moz_historyvisits - JOIN moz_places h ON h.id = place_id - WHERE url_hash = hash(:url) AND url = :url - ORDER BY date DESC LIMIT 20`); + return this._getStmt( + "/* do not warn (bug 599936) */ " + + "SELECT visit_type type, visit_date date " + + "FROM moz_historyvisits " + + "WHERE place_id = (SELECT id FROM moz_places WHERE url = :url) " + + "ORDER BY date DESC LIMIT 10"); }, _visitCols: ["date", "type"], @@ -223,10 +204,7 @@ HistoryStore.prototype = { } else { shouldApply = this._recordToPlaceInfo(record); } - } catch (ex) { - if (Async.isShutdownException(ex)) { - throw ex; - } + } catch(ex) { failed.push(record.id); shouldApply = false; } @@ -299,14 +277,14 @@ HistoryStore.prototype = { if (!visit.date || typeof visit.date != "number") { this._log.warn("Encountered record with invalid visit date: " + visit.date); - continue; + throw "Visit has no date!"; } - if (!visit.type || - !Object.values(PlacesUtils.history.TRANSITIONS).includes(visit.type)) { - this._log.warn("Encountered record with invalid visit type: " + - visit.type + "; ignoring."); - continue; + if (!visit.type || !(visit.type >= PlacesUtils.history.TRANSITION_LINK && + visit.type <= PlacesUtils.history.TRANSITION_RELOAD)) { + this._log.warn("Encountered record with invalid visit type: " + + visit.type); + throw "Invalid visit type!"; } // Dates need to be integers. @@ -317,7 +295,6 @@ HistoryStore.prototype = { // overwritten. continue; } - visit.visitDate = visit.date; visit.transitionType = visit.type; k += 1; @@ -369,9 +346,7 @@ HistoryStore.prototype = { }, wipe: function HistStore_wipe() { - let cb = Async.makeSyncCallback(); - PlacesUtils.history.clear().then(result => {cb(null, result)}, err => {cb(err)}); - return Async.waitForSyncCallback(cb); + PlacesUtils.history.removeAllPages(); } }; diff --git a/services/sync/modules/engines/passwords.js b/services/sync/modules/engines/passwords.js index 51db49a0a..0ccd2e7b0 100644 --- a/services/sync/modules/engines/passwords.js +++ b/services/sync/modules/engines/passwords.js @@ -2,16 +2,14 @@ * 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/. */ -this.EXPORTED_SYMBOLS = ['PasswordEngine', 'LoginRec', 'PasswordValidator']; +this.EXPORTED_SYMBOLS = ['PasswordEngine', 'LoginRec']; var {classes: Cc, interfaces: Ci, utils: Cu} = Components; Cu.import("resource://services-sync/record.js"); Cu.import("resource://services-sync/constants.js"); -Cu.import("resource://services-sync/collection_validator.js"); Cu.import("resource://services-sync/engines.js"); Cu.import("resource://services-sync/util.js"); -Cu.import("resource://services-common/async.js"); this.LoginRec = function LoginRec(collection, id) { CryptoWrapper.call(this, collection, id); @@ -24,7 +22,6 @@ LoginRec.prototype = { Utils.deferGetSet(LoginRec, "cleartext", [ "hostname", "formSubmitURL", "httpRealm", "username", "password", "usernameField", "passwordField", - "timeCreated", "timePasswordChanged", ]); @@ -70,10 +67,7 @@ PasswordEngine.prototype = { Svc.Prefs.set("deletePwdFxA", true); Svc.Prefs.reset("deletePwd"); // The old prefname we previously used. } catch (ex) { - if (Async.isShutdownException(ex)) { - throw ex; - } - this._log.debug("Password deletes failed", ex); + this._log.debug("Password deletes failed: ", ex); } } }, @@ -104,13 +98,6 @@ function PasswordStore(name, engine) { PasswordStore.prototype = { __proto__: Store.prototype, - _newPropertyBag: function () { - return Cc["@mozilla.org/hash-property-bag;1"].createInstance(Ci.nsIWritablePropertyBag2); - }, - - /** - * Return an instance of nsILoginInfo (and, implicitly, nsILoginMetaInfo). - */ _nsLoginInfoFromRecord: function (record) { function nullUndefined(x) { return (x == undefined) ? null : x; @@ -131,21 +118,13 @@ PasswordStore.prototype = { record.password, record.usernameField, record.passwordField); - info.QueryInterface(Ci.nsILoginMetaInfo); info.guid = record.id; - if (record.timeCreated) { - info.timeCreated = record.timeCreated; - } - if (record.timePasswordChanged) { - info.timePasswordChanged = record.timePasswordChanged; - } - return info; }, _getLoginFromGUID: function (id) { - let prop = this._newPropertyBag(); + let prop = Cc["@mozilla.org/hash-property-bag;1"].createInstance(Ci.nsIWritablePropertyBag2); prop.setPropertyAsAUTF8String("guid", id); let logins = Services.logins.searchLogins({}, prop); @@ -190,7 +169,8 @@ PasswordStore.prototype = { return; } - let prop = this._newPropertyBag(); + let prop = Cc["@mozilla.org/hash-property-bag;1"] + .createInstance(Ci.nsIWritablePropertyBag2); prop.setPropertyAsAUTF8String("guid", newID); Services.logins.modifyLogin(oldLogin, prop); @@ -217,11 +197,6 @@ PasswordStore.prototype = { record.usernameField = login.usernameField; record.passwordField = login.passwordField; - // Optional fields. - login.QueryInterface(Ci.nsILoginMetaInfo); - record.timeCreated = login.timeCreated; - record.timePasswordChanged = login.timePasswordChanged; - return record; }, @@ -237,7 +212,8 @@ PasswordStore.prototype = { try { Services.logins.addLogin(login); } catch(ex) { - this._log.debug(`Adding record ${record.id} resulted in exception`, ex); + this._log.debug("Adding record " + record.id + + " resulted in exception ", ex); } }, @@ -269,7 +245,8 @@ PasswordStore.prototype = { try { Services.logins.modifyLogin(loginItem, newinfo); } catch(ex) { - this._log.debug(`Modifying record ${record.id} resulted in exception; not modifying`, ex); + this._log.debug("Modifying record " + record.id + + " resulted in exception. Not modifying.", ex); } }, @@ -326,46 +303,3 @@ PasswordTracker.prototype = { } }, }; - -class PasswordValidator extends CollectionValidator { - constructor() { - super("passwords", "id", [ - "hostname", - "formSubmitURL", - "httpRealm", - "password", - "passwordField", - "username", - "usernameField", - ]); - } - - getClientItems() { - let logins = Services.logins.getAllLogins({}); - let syncHosts = Utils.getSyncCredentialsHosts() - let result = logins.map(l => l.QueryInterface(Ci.nsILoginMetaInfo)) - .filter(l => !syncHosts.has(l.hostname)); - return Promise.resolve(result); - } - - normalizeClientItem(item) { - return { - id: item.guid, - guid: item.guid, - hostname: item.hostname, - formSubmitURL: item.formSubmitURL, - httpRealm: item.httpRealm, - password: item.password, - passwordField: item.passwordField, - username: item.username, - usernameField: item.usernameField, - original: item, - } - } - - normalizeServerItem(item) { - return Object.assign({ guid: item.id }, item); - } -} - - diff --git a/services/sync/modules/engines/prefs.js b/services/sync/modules/engines/prefs.js index 9ceeb9ac6..792e0c66a 100644 --- a/services/sync/modules/engines/prefs.js +++ b/services/sync/modules/engines/prefs.js @@ -8,7 +8,7 @@ var Cc = Components.classes; var Ci = Components.interfaces; var Cu = Components.utils; -const PREF_SYNC_PREFS_PREFIX = "services.sync.prefs.sync."; +const SYNC_PREFS_PREFIX = "services.sync.prefs.sync."; Cu.import("resource://services-sync/engines.js"); Cu.import("resource://services-sync/record.js"); @@ -42,7 +42,6 @@ PrefsEngine.prototype = { version: 2, syncPriority: 1, - allowSkippedRecord: false, getChangedIDs: function () { // No need for a proper timestamp (no conflict resolution needed). @@ -88,45 +87,37 @@ PrefStore.prototype = { _getSyncPrefs: function () { let syncPrefs = Cc["@mozilla.org/preferences-service;1"] .getService(Ci.nsIPrefService) - .getBranch(PREF_SYNC_PREFS_PREFIX) + .getBranch(SYNC_PREFS_PREFIX) .getChildList("", {}); // Also sync preferences that determine which prefs get synced. - let controlPrefs = syncPrefs.map(pref => PREF_SYNC_PREFS_PREFIX + pref); + let controlPrefs = syncPrefs.map(pref => SYNC_PREFS_PREFIX + pref); return controlPrefs.concat(syncPrefs); }, _isSynced: function (pref) { - return pref.startsWith(PREF_SYNC_PREFS_PREFIX) || - this._prefs.get(PREF_SYNC_PREFS_PREFIX + pref, false); + return pref.startsWith(SYNC_PREFS_PREFIX) || + this._prefs.get(SYNC_PREFS_PREFIX + pref, false); }, _getAllPrefs: function () { let values = {}; - for (let pref of this._getSyncPrefs()) { + for each (let pref in this._getSyncPrefs()) { if (this._isSynced(pref)) { - // Missing and default prefs get the null value. - values[pref] = this._prefs.isSet(pref) ? this._prefs.get(pref, null) : null; + // Missing prefs get the null value. + values[pref] = this._prefs.get(pref, null); } } return values; }, - _updateLightWeightTheme (themeID) { - let themeObject = null; - if (themeID) { - themeObject = LightweightThemeManager.getUsedTheme(themeID); - } - LightweightThemeManager.currentTheme = themeObject; - }, - _setAllPrefs: function (values) { - let selectedThemeIDPref = "lightweightThemes.selectedThemeID"; - let selectedThemeIDBefore = this._prefs.get(selectedThemeIDPref, null); - let selectedThemeIDAfter = selectedThemeIDBefore; + let enabledPref = "lightweightThemes.isThemeSelected"; + let enabledBefore = this._prefs.get(enabledPref, false); + let prevTheme = LightweightThemeManager.currentTheme; // Update 'services.sync.prefs.sync.foo.pref' before 'foo.pref', otherwise // _isSynced returns false when 'foo.pref' doesn't exist (e.g., on a new device). - let prefs = Object.keys(values).sort(a => -a.indexOf(PREF_SYNC_PREFS_PREFIX)); + let prefs = Object.keys(values).sort(a => -a.indexOf(SYNC_PREFS_PREFIX)); for (let pref of prefs) { if (!this._isSynced(pref)) { continue; @@ -134,30 +125,26 @@ PrefStore.prototype = { let value = values[pref]; - switch (pref) { - // Some special prefs we don't want to set directly. - case selectedThemeIDPref: - selectedThemeIDAfter = value; - break; - - // default is to just set the pref - default: - if (value == null) { - // Pref has gone missing. The best we can do is reset it. - this._prefs.reset(pref); - } else { - try { - this._prefs.set(pref, value); - } catch(ex) { - this._log.trace("Failed to set pref: " + pref + ": " + ex); - } - } + // Pref has gone missing. The best we can do is reset it. + if (value == null) { + this._prefs.reset(pref); + continue; } + + try { + this._prefs.set(pref, value); + } catch(ex) { + this._log.trace("Failed to set pref: " + pref + ": " + ex); + } } - // Notify the lightweight theme manager if the selected theme has changed. - if (selectedThemeIDBefore != selectedThemeIDAfter) { - this._updateLightWeightTheme(selectedThemeIDAfter); + // Notify the lightweight theme manager of all the new values + let enabledNow = this._prefs.get(enabledPref, false); + if (enabledBefore && !enabledNow) { + LightweightThemeManager.currentTheme = null; + } else if (enabledNow && LightweightThemeManager.usedThemes[0] != prevTheme) { + LightweightThemeManager.currentTheme = null; + LightweightThemeManager.currentTheme = LightweightThemeManager.usedThemes[0]; } }, @@ -261,8 +248,8 @@ PrefTracker.prototype = { case "nsPref:changed": // Trigger a sync for MULTI-DEVICE for a change that determines // which prefs are synced or a regular pref change. - if (data.indexOf(PREF_SYNC_PREFS_PREFIX) == 0 || - this._prefs.get(PREF_SYNC_PREFS_PREFIX + data, false)) { + if (data.indexOf(SYNC_PREFS_PREFIX) == 0 || + this._prefs.get(SYNC_PREFS_PREFIX + data, false)) { this.score += SCORE_INCREMENT_XLARGE; this.modified = true; this._log.trace("Preference " + data + " changed"); diff --git a/services/sync/modules/engines/tabs.js b/services/sync/modules/engines/tabs.js index 45ece4a23..167faf625 100644 --- a/services/sync/modules/engines/tabs.js +++ b/services/sync/modules/engines/tabs.js @@ -43,11 +43,6 @@ TabEngine.prototype = { _storeObj: TabStore, _trackerObj: TabTracker, _recordObj: TabSetRecord, - // A flag to indicate if we have synced in this session. This is to help - // consumers of remote tabs that may want to differentiate between "I've an - // empty tab list as I haven't yet synced" vs "I've an empty tab list - // as there really are no tabs" - hasSyncedThisSession: false, syncPriority: 3, @@ -72,7 +67,6 @@ TabEngine.prototype = { SyncEngine.prototype._resetClient.call(this); this._store.wipe(); this._tracker.modified = true; - this.hasSyncedThisSession = false; }, removeClientData: function () { @@ -100,12 +94,7 @@ TabEngine.prototype = { } return SyncEngine.prototype._reconcile.call(this, item); - }, - - _syncFinish() { - this.hasSyncedThisSession = true; - return SyncEngine.prototype._syncFinish.call(this); - }, + } }; @@ -145,7 +134,7 @@ TabStore.prototype = { } for (let tab of win.gBrowser.tabs) { - let tabState = this.getTabState(tab); + tabState = this.getTabState(tab); // Make sure there are history entries to look at. if (!tabState || !tabState.entries.length) { @@ -165,11 +154,6 @@ TabStore.prototype = { continue; } - if (current.url.length >= (MAX_UPLOAD_BYTES - 1000)) { - this._log.trace("Skipping over-long URL."); - continue; - } - // The element at `index` is the current page. Previous URLs were // previously visited URLs; subsequent URLs are in the 'forward' stack, // which we can't represent in Sync, so we truncate here. @@ -189,9 +173,7 @@ TabStore.prototype = { allTabs.push({ title: current.title || "", urlHistory: urls, - icon: tabState.image || - (tabState.attributes && tabState.attributes.image) || - "", + icon: tabState.attributes && tabState.attributes.image || "", lastUsed: Math.floor((tabState.lastAccessed || 0) / 1000), }); } @@ -265,9 +247,27 @@ TabStore.prototype = { create: function (record) { this._log.debug("Adding remote tabs from " + record.clientName); - this._remoteClients[record.id] = Object.assign({}, record.cleartext, { - lastModified: record.modified - }); + this._remoteClients[record.id] = record.cleartext; + + // Lose some precision, but that's good enough (seconds). + let roundModify = Math.floor(record.modified / 1000); + let notifyState = Svc.Prefs.get("notifyTabState"); + + // If there's no existing pref, save this first modified time. + if (notifyState == null) { + Svc.Prefs.set("notifyTabState", roundModify); + return; + } + + // Don't change notifyState if it's already 0 (don't notify). + if (notifyState == 0) { + return; + } + + // We must have gotten a new tab that isn't the same as last time. + if (notifyState != roundModify) { + Svc.Prefs.set("notifyTabState", 0); + } }, update: function (record) { @@ -306,10 +306,6 @@ TabTracker.prototype = { window.addEventListener(topic, this.onTab, false); } window.addEventListener("unload", this._unregisterListeners, false); - // If it's got a tab browser we can listen for things like navigation. - if (window.gBrowser) { - window.gBrowser.addProgressListener(this); - } }, _unregisterListeners: function (event) { @@ -322,9 +318,6 @@ TabTracker.prototype = { for (let topic of this._topics) { window.removeEventListener(topic, this.onTab, false); } - if (window.gBrowser) { - window.gBrowser.removeProgressListener(this); - } }, startTracking: function () { @@ -380,14 +373,4 @@ TabTracker.prototype = { this.score += SCORE_INCREMENT_SMALL; } }, - - // web progress listeners. - onLocationChange: function (webProgress, request, location, flags) { - // We only care about top-level location changes which are not in the same - // document. - if (webProgress.isTopLevel && - ((flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) == 0)) { - this.modified = true; - } - }, }; diff --git a/services/sync/modules/identity.js b/services/sync/modules/identity.js index b4da8c0bb..795901f89 100644 --- a/services/sync/modules/identity.js +++ b/services/sync/modules/identity.js @@ -13,7 +13,6 @@ Cu.import("resource://gre/modules/Promise.jsm"); Cu.import("resource://services-sync/constants.js"); Cu.import("resource://gre/modules/Log.jsm"); Cu.import("resource://services-sync/util.js"); -Cu.import("resource://services-common/async.js"); // Lazy import to prevent unnecessary load on startup. for (let symbol of ["BulkKeyBundle", "SyncKeyBundle"]) { @@ -23,8 +22,7 @@ for (let symbol of ["BulkKeyBundle", "SyncKeyBundle"]) { } /** - * Manages "legacy" identity and authentication for Sync. - * See browserid_identity for the Firefox Accounts based identity manager. + * Manages identity and authentication for Sync. * * The following entities are managed: * @@ -85,14 +83,18 @@ IdentityManager.prototype = { _syncKeyBundle: null, /** - * Initialize the identity provider. + * Initialize the identity provider. Returns a promise that is resolved + * when initialization is complete and the provider can be queried for + * its state */ initialize: function() { // Nothing to do for this identity provider. + return Promise.resolve(); }, finalize: function() { // Nothing to do for this identity provider. + return Promise.resolve(); }, /** @@ -111,6 +113,14 @@ IdentityManager.prototype = { return Promise.resolve(); }, + /** + * Indicates if the identity manager is still initializing + */ + get readyToAuthenticate() { + // We initialize in a fully sync manner, so we are always finished. + return true; + }, + get account() { return Svc.Prefs.get("account", this.username); }, @@ -326,7 +336,7 @@ IdentityManager.prototype = { try { this._syncKeyBundle = new SyncKeyBundle(this.username, this.syncKey); } catch (ex) { - this._log.warn("Failed to create sync bundle", ex); + this._log.warn("Failed to create sync key bundle", ex); return null; } } @@ -447,9 +457,6 @@ IdentityManager.prototype = { try { service.recordManager.get(service.storageURL + "meta/fxa_credentials"); } catch (ex) { - if (Async.isShutdownException(ex)) { - throw ex; - } this._log.warn("Failed to pre-fetch the migration sentinel", ex); } }, @@ -593,13 +600,4 @@ IdentityManager.prototype = { // Do nothing for Sync 1.1. return {accepted: true}; }, - - // Tell Sync what the login status should be if it saw a 401 fetching - // info/collections as part of login verification (typically immediately - // after login.) - // In our case it means an authoritative "password is incorrect". - loginStatusFromVerification404() { - return LOGIN_FAILED_LOGIN_REJECTED; - } - }; diff --git a/services/sync/modules/notifications.js b/services/sync/modules/notifications.js index 72187a4ce..5a67a7414 100644 --- a/services/sync/modules/notifications.js +++ b/services/sync/modules/notifications.js @@ -119,7 +119,7 @@ this.NotificationButton = callback.apply(this, arguments); } catch (e) { let logger = Log.repository.getLogger("Sync.Notifications"); - logger.error("An exception occurred: " + Utils.exceptionStr(e)); + logger.error("An exception occurred: ", e); logger.info(Utils.stackTrace(e)); throw e; } diff --git a/services/sync/modules/policies.js b/services/sync/modules/policies.js index a3933426d..2d85b1428 100644 --- a/services/sync/modules/policies.js +++ b/services/sync/modules/policies.js @@ -14,19 +14,9 @@ Cu.import("resource://services-sync/constants.js"); Cu.import("resource://services-sync/engines.js"); Cu.import("resource://services-sync/util.js"); Cu.import("resource://services-common/logmanager.js"); -Cu.import("resource://services-common/async.js"); XPCOMUtils.defineLazyModuleGetter(this, "Status", "resource://services-sync/status.js"); -XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", - "resource://gre/modules/AddonManager.jsm"); - -// Get the value for an interval that's stored in preferences. To save users -// from themselves (and us from them!) the minimum time they can specify -// is 60s. -function getThrottledIntervalPreference(prefName) { - return Math.max(Svc.Prefs.get(prefName), 60) * 1000; -} this.SyncScheduler = function SyncScheduler(service) { this.service = service; @@ -53,14 +43,13 @@ SyncScheduler.prototype = { .getService(Ci.nsISupports) .wrappedJSObject; - let part = service.fxAccountsEnabled ? "fxa" : "sync11"; - let prefSDInterval = "scheduler." + part + ".singleDeviceInterval"; - this.singleDeviceInterval = getThrottledIntervalPreference(prefSDInterval); + let prefSDInterval = "scheduler.sync11.singleDeviceInterval"; + this.singleDeviceInterval = Svc.Prefs.get(prefSDInterval) * 1000; - this.idleInterval = getThrottledIntervalPreference("scheduler.idleInterval"); - this.activeInterval = getThrottledIntervalPreference("scheduler.activeInterval"); - this.immediateInterval = getThrottledIntervalPreference("scheduler.immediateInterval"); - this.eolInterval = getThrottledIntervalPreference("scheduler.eolInterval"); + this.idleInterval = Svc.Prefs.get("scheduler.idleInterval") * 1000; + this.activeInterval = Svc.Prefs.get("scheduler.activeInterval") * 1000; + this.immediateInterval = Svc.Prefs.get("scheduler.immediateInterval") * 1000; + this.eolInterval = Svc.Prefs.get("scheduler.eolInterval") * 1000; // A user is non-idle on startup by default. this.idle = false; @@ -71,40 +60,20 @@ SyncScheduler.prototype = { }, // nextSync is in milliseconds, but prefs can't hold that much - get nextSync() { - return Svc.Prefs.get("nextSync", 0) * 1000; - }, - set nextSync(value) { - Svc.Prefs.set("nextSync", Math.floor(value / 1000)); - }, + get nextSync() Svc.Prefs.get("nextSync", 0) * 1000, + set nextSync(value) Svc.Prefs.set("nextSync", Math.floor(value / 1000)), - get syncInterval() { - return Svc.Prefs.get("syncInterval", this.singleDeviceInterval); - }, - set syncInterval(value) { - Svc.Prefs.set("syncInterval", value); - }, + get syncInterval() Svc.Prefs.get("syncInterval", this.singleDeviceInterval), + set syncInterval(value) Svc.Prefs.set("syncInterval", value), - get syncThreshold() { - return Svc.Prefs.get("syncThreshold", SINGLE_USER_THRESHOLD); - }, - set syncThreshold(value) { - Svc.Prefs.set("syncThreshold", value); - }, + get syncThreshold() Svc.Prefs.get("syncThreshold", SINGLE_USER_THRESHOLD), + set syncThreshold(value) Svc.Prefs.set("syncThreshold", value), - get globalScore() { - return Svc.Prefs.get("globalScore", 0); - }, - set globalScore(value) { - Svc.Prefs.set("globalScore", value); - }, + get globalScore() Svc.Prefs.get("globalScore", 0), + set globalScore(value) Svc.Prefs.set("globalScore", value), - get numClients() { - return Svc.Prefs.get("numClients", 0); - }, - set numClients(value) { - Svc.Prefs.set("numClients", value); - }, + get numClients() Svc.Prefs.get("numClients", 0), + set numClients(value) Svc.Prefs.set("numClients", value), init: function init() { this._log.level = Log.Level[Svc.Prefs.get("log.logger.service.main")]; @@ -249,10 +218,7 @@ SyncScheduler.prototype = { this.setDefaults(); try { Svc.Idle.removeIdleObserver(this, Svc.Prefs.get("scheduler.idleTime")); - } catch (ex) { - if (ex.result != Cr.NS_ERROR_FAILURE) { - throw ex; - } + } catch (ex if (ex.result == Cr.NS_ERROR_FAILURE)) { // In all likelihood we didn't have an idle observer registered yet. // It's all good. } @@ -285,11 +251,10 @@ SyncScheduler.prototype = { case "wake_notification": this._log.debug("Woke from sleep."); Utils.nextTick(() => { - // Trigger a sync if we have multiple clients. We give it 5 seconds - // incase the network is still in the process of coming back up. + // Trigger a sync if we have multiple clients. if (this.numClients > 1) { - this._log.debug("More than 1 client. Will sync in 5s."); - this.scheduleNextSync(5000); + this._log.debug("More than 1 client. Syncing."); + this.scheduleNextSync(0); } }); break; @@ -531,6 +496,45 @@ SyncScheduler.prototype = { this.syncTimer.clear(); }, + /** + * Prevent new syncs from starting. This is used by the FxA migration code + * where we can't afford to have a sync start partway through the migration. + * To handle the edge-case of a sync starting and not stopping, we store + * this state in a pref, so on the next startup we remain blocked (and thus + * sync will never start) so the migration can complete. + * + * As a safety measure, we only block for some period of time, and after + * that it will automatically unblock. This ensures that if things go + * really pear-shaped and we never end up calling unblockSync() we haven't + * completely broken the world. + */ + blockSync: function(until = null) { + if (!until) { + until = Date.now() + DEFAULT_BLOCK_PERIOD; + } + // until is specified in ms, but Prefs can't hold that much + Svc.Prefs.set("scheduler.blocked-until", Math.floor(until / 1000)); + }, + + unblockSync: function() { + Svc.Prefs.reset("scheduler.blocked-until"); + // the migration code should be ready to roll, so resume normal operations. + this.checkSyncStatus(); + }, + + get isBlocked() { + let until = Svc.Prefs.get("scheduler.blocked-until"); + if (until === undefined) { + return false; + } + if (until <= Math.floor(Date.now() / 1000)) { + // we were previously blocked but the time has expired. + Svc.Prefs.reset("scheduler.blocked-until"); + return false; + } + // we remain blocked. + return true; + }, }; this.ErrorHandler = function ErrorHandler(service) { @@ -570,10 +574,7 @@ ErrorHandler.prototype = { root.level = Log.Level[Svc.Prefs.get("log.rootLogger")]; let logs = ["Sync", "FirefoxAccounts", "Hawk", "Common.TokenServerClient", - "Sync.SyncMigration", "browserwindow.syncui", - "Services.Common.RESTRequest", "Services.Common.RESTRequest", - "BookmarkSyncUtils" - ]; + "Sync.SyncMigration"]; this._logManager = new LogManager(Svc.Prefs, logs, "sync"); }, @@ -590,25 +591,17 @@ ErrorHandler.prototype = { this._log.debug(data + " failed to apply some records."); } break; - case "weave:engine:sync:error": { + case "weave:engine:sync:error": let exception = subject; // exception thrown by engine's sync() method let engine_name = data; // engine name that threw the exception this.checkServerError(exception); Status.engines = [engine_name, exception.failureCode || ENGINE_UNKNOWN_FAIL]; - if (Async.isShutdownException(exception)) { - this._log.debug(engine_name + " was interrupted due to the application shutting down"); - } else { - this._log.debug(engine_name + " failed", exception); - Services.telemetry.getKeyedHistogramById("WEAVE_ENGINE_SYNC_ERRORS") - .add(engine_name); - } + this._log.debug(engine_name + " failed: ", exception); break; - } case "weave:service:login:error": - this._log.error("Sync encountered a login error"); - this.resetFileLog(); + this.resetFileLog(this._logManager.REASON_ERROR); if (this.shouldReportError()) { this.notifyOnNextTick("weave:ui:login:error"); @@ -618,23 +611,12 @@ ErrorHandler.prototype = { this.dontIgnoreErrors = false; break; - case "weave:service:sync:error": { + case "weave:service:sync:error": if (Status.sync == CREDENTIALS_CHANGED) { this.service.logout(); } - let exception = subject; - if (Async.isShutdownException(exception)) { - // If we are shutting down we just log the fact, attempt to flush - // the log file and get out of here! - this._log.error("Sync was interrupted due to the application shutting down"); - this.resetFileLog(); - break; - } - - // Not a shutdown related exception... - this._log.error("Sync encountered an error", exception); - this.resetFileLog(); + this.resetFileLog(this._logManager.REASON_ERROR); if (this.shouldReportError()) { this.notifyOnNextTick("weave:ui:sync:error"); @@ -644,7 +626,6 @@ ErrorHandler.prototype = { this.dontIgnoreErrors = false; break; - } case "weave:service:sync:finish": this._log.trace("Status.service is " + Status.service); @@ -660,8 +641,8 @@ ErrorHandler.prototype = { } if (Status.service == SYNC_FAILED_PARTIAL) { - this._log.error("Some engines did not sync correctly."); - this.resetFileLog(); + this._log.debug("Some engines did not sync correctly."); + this.resetFileLog(this._logManager.REASON_ERROR); if (this.shouldReportError()) { this.dontIgnoreErrors = false; @@ -669,7 +650,7 @@ ErrorHandler.prototype = { break; } } else { - this.resetFileLog(); + this.resetFileLog(this._logManager.REASON_SUCCESS); } this.dontIgnoreErrors = false; this.notifyOnNextTick("weave:ui:sync:finish"); @@ -696,52 +677,22 @@ ErrorHandler.prototype = { Utils.nextTick(this.service.sync, this.service); }, - _dumpAddons: function _dumpAddons() { - // Just dump the items that sync may be concerned with. Specifically, - // active extensions that are not hidden. - let addonPromise = new Promise(resolve => { - try { - AddonManager.getAddonsByTypes(["extension"], resolve); - } catch (e) { - this._log.warn("Failed to dump addons", e) - resolve([]) - } - }); - - return addonPromise.then(addons => { - let relevantAddons = addons.filter(x => x.isActive && !x.hidden); - this._log.debug("Addons installed", relevantAddons.length); - for (let addon of relevantAddons) { - this._log.debug(" - ${name}, version ${version}, id ${id}", addon); - } - }); - }, - /** * Generate a log file for the sync that just completed * and refresh the input & output streams. + * + * @param reason + * A constant from the LogManager that indicates the reason for the + * reset. */ - resetFileLog: function resetFileLog() { - let onComplete = logType => { + resetFileLog: function resetFileLog(reason) { + let onComplete = () => { Svc.Obs.notify("weave:service:reset-file-log"); this._log.trace("Notified: " + Date.now()); - if (logType == this._logManager.ERROR_LOG_WRITTEN) { - Cu.reportError("Sync encountered an error - see about:sync-log for the log file."); - } }; - - // If we're writing an error log, dump extensions that may be causing problems. - let beforeResetLog; - if (this._logManager.sawError) { - beforeResetLog = this._dumpAddons(); - } else { - beforeResetLog = Promise.resolve(); - } // Note we do not return the promise here - the caller doesn't need to wait // for this to complete. - beforeResetLog - .then(() => this._logManager.resetFileLog()) - .then(onComplete, onComplete); + this._logManager.resetFileLog(reason).then(onComplete, onComplete); }, /** @@ -775,9 +726,6 @@ ErrorHandler.prototype = { } }, - // A function to indicate if Sync errors should be "reported" - which in this - // context really means "should be notify observers of an error" - but note - // that since bug 1180587, no one is going to surface an error to the user. shouldReportError: function shouldReportError() { if (Status.login == MASTER_PASSWORD_LOCKED) { this._log.trace("shouldReportError: false (master password locked)."); @@ -817,12 +765,8 @@ ErrorHandler.prototype = { return false; } - - let result = ([Status.login, Status.sync].indexOf(SERVER_MAINTENANCE) == -1 && - [Status.login, Status.sync].indexOf(LOGIN_FAILED_NETWORK_ERROR) == -1); - this._log.trace("shouldReportError: ${result} due to login=${login}, sync=${sync}", - {result, login: Status.login, sync: Status.sync}); - return result; + return ([Status.login, Status.sync].indexOf(SERVER_MAINTENANCE) == -1 && + [Status.login, Status.sync].indexOf(LOGIN_FAILED_NETWORK_ERROR) == -1); }, get currentAlertMode() { @@ -925,7 +869,7 @@ ErrorHandler.prototype = { case 401: this.service.logout(); this._log.info("Got 401 response; resetting clusterURL."); - this.service.clusterURL = null; + Svc.Prefs.reset("clusterURL"); let delay = 0; if (Svc.Prefs.get("lastSyncReassigned")) { diff --git a/services/sync/modules/record.js b/services/sync/modules/record.js index f7a69d9ef..5dc1c012c 100644 --- a/services/sync/modules/record.js +++ b/services/sync/modules/record.js @@ -23,7 +23,6 @@ Cu.import("resource://services-sync/constants.js"); Cu.import("resource://services-sync/keys.js"); Cu.import("resource://services-sync/resource.js"); Cu.import("resource://services-sync/util.js"); -Cu.import("resource://services-common/async.js"); this.WBORecord = function WBORecord(collection, id) { this.data = {}; @@ -196,9 +195,7 @@ CryptoWrapper.prototype = { }, // The custom setter below masks the parent's getter, so explicitly call it :( - get id() { - return WBORecord.prototype.__lookupGetter__("id").call(this); - }, + get id() WBORecord.prototype.__lookupGetter__("id").call(this), // Keep both plaintext and encrypted versions of the id to verify integrity set id(val) { @@ -238,11 +235,8 @@ RecordManager.prototype = { record.deserialize(this.response); return this.set(url, record); - } catch (ex) { - if (Async.isShutdownException(ex)) { - throw ex; - } - this._log.debug("Failed to import record", ex); + } catch(ex) { + this._log.debug("Failed to import record: ", ex); return null; } }, @@ -281,10 +275,10 @@ RecordManager.prototype = { * You can update this thing simply by giving it /info/collections. It'll * use the last modified time to bring itself up to date. */ -this.CollectionKeyManager = function CollectionKeyManager(lastModified, default_, collections) { - this.lastModified = lastModified || 0; - this._default = default_ || null; - this._collections = collections || {}; +this.CollectionKeyManager = function CollectionKeyManager() { + this.lastModified = 0; + this._collections = {}; + this._default = null; this._log = Log.repository.getLogger("Sync.CollectionKeyManager"); } @@ -293,19 +287,6 @@ this.CollectionKeyManager = function CollectionKeyManager(lastModified, default_ // Note that the last modified time needs to be preserved. CollectionKeyManager.prototype = { - /** - * Generate a new CollectionKeyManager that has the same attributes - * as this one. - */ - clone() { - const newCollections = {}; - for (let c in this._collections) { - newCollections[c] = this._collections[c]; - } - - return new CollectionKeyManager(this.lastModified, this._default, newCollections); - }, - // Return information about old vs new keys: // * same: true if two collections are equal // * changed: an array of collection names that changed. @@ -374,15 +355,15 @@ CollectionKeyManager.prototype = { /** * Create a WBO for the current keys. */ - asWBO: function(collection, id) { - return this._makeWBO(this._collections, this._default); - }, + asWBO: function(collection, id) + this._makeWBO(this._collections, this._default), /** * Compute a new default key, and new keys for any specified collections. */ newKeys: function(collections) { - let newDefaultKeyBundle = this.newDefaultKeyBundle(); + let newDefaultKey = new BulkKeyBundle(DEFAULT_KEYBUNDLE_NAME); + newDefaultKey.generateRandom(); let newColls = {}; if (collections) { @@ -392,7 +373,7 @@ CollectionKeyManager.prototype = { newColls[c] = b; }); } - return [newDefaultKeyBundle, newColls]; + return [newDefaultKey, newColls]; }, /** @@ -406,57 +387,6 @@ CollectionKeyManager.prototype = { return this._makeWBO(newColls, newDefaultKey); }, - /** - * Create a new default key. - * - * @returns {BulkKeyBundle} - */ - newDefaultKeyBundle() { - const key = new BulkKeyBundle(DEFAULT_KEYBUNDLE_NAME); - key.generateRandom(); - return key; - }, - - /** - * Create a new default key and store it as this._default, since without one you cannot use setContents. - */ - generateDefaultKey() { - this._default = this.newDefaultKeyBundle(); - }, - - /** - * Return true if keys are already present for each of the given - * collections. - */ - hasKeysFor(collections) { - // We can't use filter() here because sometimes collections is an iterator. - for (let collection of collections) { - if (!this._collections[collection]) { - return false; - } - } - return true; - }, - - /** - * Return a new CollectionKeyManager that has keys for each of the - * given collections (creating new ones for collections where we - * don't already have keys). - */ - ensureKeysFor(collections) { - const newKeys = Object.assign({}, this._collections); - for (let c of collections) { - if (newKeys[c]) { - continue; // don't replace existing keys - } - - const b = new BulkKeyBundle(c); - b.generateRandom(); - newKeys[c] = b; - } - return new CollectionKeyManager(this.lastModified, this._default, newKeys); - }, - // Take the fetched info/collections WBO, checking the change // time of the crypto collection. updateNeeded: function(info_collections) { @@ -487,6 +417,9 @@ CollectionKeyManager.prototype = { // setContents: function setContents(payload, modified) { + if (!modified) + throw "No modified time provided to setContents."; + let self = this; this._log.info("Setting collection keys contents. Our last modified: " + @@ -516,7 +449,9 @@ CollectionKeyManager.prototype = { if (v) { let keyObj = new BulkKeyBundle(k); keyObj.keyPairB64 = v; - newCollections[k] = keyObj; + if (keyObj) { + newCollections[k] = keyObj; + } } } } @@ -527,11 +462,8 @@ CollectionKeyManager.prototype = { let sameColls = collComparison.same; if (sameDefault && sameColls) { - self._log.info("New keys are the same as our old keys!"); - if (modified) { - self._log.info("Bumped local modified time."); - self.lastModified = modified; - } + self._log.info("New keys are the same as our old keys! Bumped local modified time."); + self.lastModified = modified; return false; } @@ -543,10 +475,8 @@ CollectionKeyManager.prototype = { this._collections = newCollections; // Always trust the server. - if (modified) { - self._log.info("Bumping last modified to " + modified); - self.lastModified = modified; - } + self._log.info("Bumping last modified to " + modified); + self.lastModified = modified; return sameDefault ? collComparison.changed : true; }, @@ -594,12 +524,6 @@ this.Collection = function Collection(uri, recordObj, service) { this._older = 0; this._newer = 0; this._data = []; - // optional members used by batch upload operations. - this._batch = null; - this._commit = false; - // Used for batch download operations -- note that this is explicitly an - // opaque value and not (necessarily) a number. - this._offset = null; } Collection.prototype = { __proto__: Resource.prototype, @@ -623,12 +547,6 @@ Collection.prototype = { args.push("ids=" + this.ids); if (this.limit > 0 && this.limit != Infinity) args.push("limit=" + this.limit); - if (this._batch) - args.push("batch=" + encodeURIComponent(this._batch)); - if (this._commit) - args.push("commit=true"); - if (this._offset) - args.push("offset=" + encodeURIComponent(this._offset)); this.uri.query = (args.length > 0)? '?' + args.join('&') : ''; }, @@ -641,14 +559,14 @@ Collection.prototype = { }, // Apply the action to a certain set of ids - get ids() { return this._ids; }, + get ids() this._ids, set ids(value) { this._ids = value; this._rebuildURL(); }, // Limit how many records to get - get limit() { return this._limit; }, + get limit() this._limit, set limit(value) { this._limit = value; this._rebuildURL(); @@ -678,100 +596,12 @@ Collection.prototype = { this._rebuildURL(); }, - get offset() { return this._offset; }, - set offset(value) { - this._offset = value; - this._rebuildURL(); - }, - - // Set information about the batch for this request. - get batch() { return this._batch; }, - set batch(value) { - this._batch = value; - this._rebuildURL(); - }, - - get commit() { return this._commit; }, - set commit(value) { - this._commit = value && true; - this._rebuildURL(); + pushData: function Coll_pushData(data) { + this._data.push(data); }, - // Similar to get(), but will page through the items `batchSize` at a time, - // deferring calling the record handler until we've gotten them all. - // - // Returns the last response processed, and doesn't run the record handler - // on any items if a non-success status is received while downloading the - // records (or if a network error occurs). - getBatched(batchSize = DEFAULT_DOWNLOAD_BATCH_SIZE) { - let totalLimit = Number(this.limit) || Infinity; - if (batchSize <= 0 || batchSize >= totalLimit) { - // Invalid batch sizes should arguably be an error, but they're easy to handle - return this.get(); - } - - if (!this.full) { - throw new Error("getBatched is unimplemented for guid-only GETs"); - } - - // _onComplete and _onProgress are reset after each `get` by AsyncResource. - // We overwrite _onRecord to something that stores the data in an array - // until the end. - let { _onComplete, _onProgress, _onRecord } = this; - let recordBuffer = []; - let resp; - try { - this._onRecord = r => recordBuffer.push(r); - let lastModifiedTime; - this.limit = batchSize; - - do { - this._onProgress = _onProgress; - this._onComplete = _onComplete; - if (batchSize + recordBuffer.length > totalLimit) { - this.limit = totalLimit - recordBuffer.length; - } - this._log.trace("Performing batched GET", { limit: this.limit, offset: this.offset }); - // Actually perform the request - resp = this.get(); - if (!resp.success) { - break; - } - - // Initialize last modified, or check that something broken isn't happening. - let lastModified = resp.headers["x-last-modified"]; - if (!lastModifiedTime) { - lastModifiedTime = lastModified; - this.setHeader("X-If-Unmodified-Since", lastModified); - } else if (lastModified != lastModifiedTime) { - // Should be impossible -- We'd get a 412 in this case. - throw new Error("X-Last-Modified changed in the middle of a download batch! " + - `${lastModified} => ${lastModifiedTime}`) - } - - // If this is missing, we're finished. - this.offset = resp.headers["x-weave-next-offset"]; - } while (this.offset && totalLimit > recordBuffer.length); - } finally { - // Ensure we undo any temporary state so that subsequent calls to get() - // or getBatched() work properly. We do this before calling the record - // handler so that we can more convincingly pretend to be a normal get() - // call. Note: we're resetting these to the values they had before this - // function was called. - this._onRecord = _onRecord; - this._limit = totalLimit; - this._offset = null; - delete this._headers["x-if-unmodified-since"]; - this._rebuildURL(); - } - if (resp.success && Async.checkAppReady()) { - // call the original _onRecord (e.g. the user supplied record handler) - // for each record we've stored - for (let record of recordBuffer) { - this._onRecord(record); - } - } - return resp; + clearRecords: function Coll_clearRecords() { + this._data = []; }, set recordHandler(onRecord) { @@ -781,8 +611,6 @@ Collection.prototype = { // Switch to newline separated records for incremental parsing coll.setHeader("Accept", "application/newlines"); - this._onRecord = onRecord; - this._onProgress = function() { let newline; while ((newline = this._data.indexOf("\n")) > 0) { @@ -793,247 +621,8 @@ Collection.prototype = { // Deserialize a record from json and give it to the callback let record = new coll._recordObj(); record.deserialize(json); - coll._onRecord(record); + onRecord(record); } }; }, - - // This object only supports posting via the postQueue object. - post() { - throw new Error("Don't directly post to a collection - use newPostQueue instead"); - }, - - newPostQueue(log, timestamp, postCallback) { - let poster = (data, headers, batch, commit) => { - this.batch = batch; - this.commit = commit; - for (let [header, value] of headers) { - this.setHeader(header, value); - } - return Resource.prototype.post.call(this, data); - } - let getConfig = (name, defaultVal) => { - if (this._service.serverConfiguration && this._service.serverConfiguration.hasOwnProperty(name)) { - return this._service.serverConfiguration[name]; - } - return defaultVal; - } - - let config = { - max_post_bytes: getConfig("max_post_bytes", MAX_UPLOAD_BYTES), - max_post_records: getConfig("max_post_records", MAX_UPLOAD_RECORDS), - - max_batch_bytes: getConfig("max_total_bytes", Infinity), - max_batch_records: getConfig("max_total_records", Infinity), - } - - // Handle config edge cases - if (config.max_post_records <= 0) { config.max_post_records = MAX_UPLOAD_RECORDS; } - if (config.max_batch_records <= 0) { config.max_batch_records = Infinity; } - if (config.max_post_bytes <= 0) { config.max_post_bytes = MAX_UPLOAD_BYTES; } - if (config.max_batch_bytes <= 0) { config.max_batch_bytes = Infinity; } - - // Max size of BSO payload is 256k. This assumes at most 4k of overhead, - // which sounds like plenty. If the server says it can't handle this, we - // might have valid records we can't sync, so we give up on syncing. - let requiredMax = 260 * 1024; - if (config.max_post_bytes < requiredMax) { - this._log.error("Server configuration max_post_bytes is too low", config); - throw new Error("Server configuration max_post_bytes is too low"); - } - - return new PostQueue(poster, timestamp, config, log, postCallback); - }, }; - -/* A helper to manage the posting of records while respecting the various - size limits. - - This supports the concept of a server-side "batch". The general idea is: - * We queue as many records as allowed in memory, then make a single POST. - * This first POST (optionally) gives us a batch ID, which we use for - all subsequent posts, until... - * At some point we hit a batch-maximum, and jump through a few hoops to - commit the current batch (ie, all previous POSTs) and start a new one. - * Eventually commit the final batch. - - In most cases we expect there to be exactly 1 batch consisting of possibly - multiple POSTs. -*/ -function PostQueue(poster, timestamp, config, log, postCallback) { - // The "post" function we should use when it comes time to do the post. - this.poster = poster; - this.log = log; - - // The config we use. We expect it to have fields "max_post_records", - // "max_batch_records", "max_post_bytes", and "max_batch_bytes" - this.config = config; - - // The callback we make with the response when we do get around to making the - // post (which could be during any of the enqueue() calls or the final flush()) - // This callback may be called multiple times and must not add new items to - // the queue. - // The second argument passed to this callback is a boolean value that is true - // if we're in the middle of a batch, and false if either the batch is - // complete, or it's a post to a server that does not understand batching. - this.postCallback = postCallback; - - // The string where we are capturing the stringified version of the records - // queued so far. It will always be invalid JSON as it is always missing the - // closing bracket. - this.queued = ""; - - // The number of records we've queued so far but are yet to POST. - this.numQueued = 0; - - // The number of records/bytes we've processed in previous POSTs for our - // current batch. Does *not* include records currently queued for the next POST. - this.numAlreadyBatched = 0; - this.bytesAlreadyBatched = 0; - - // The ID of our current batch. Can be undefined (meaning we are yet to make - // the first post of a patch, so don't know if we have a batch), null (meaning - // we've made the first post but the server response indicated no batching - // semantics), otherwise we have made the first post and it holds the batch ID - // returned from the server. - this.batchID = undefined; - - // Time used for X-If-Unmodified-Since -- should be the timestamp from the last GET. - this.lastModified = timestamp; -} - -PostQueue.prototype = { - enqueue(record) { - // We want to ensure the record has a .toJSON() method defined - even - // though JSON.stringify() would implicitly call it, the stringify might - // still work even if it isn't defined, which isn't what we want. - let jsonRepr = record.toJSON(); - if (!jsonRepr) { - throw new Error("You must only call this with objects that explicitly support JSON"); - } - let bytes = JSON.stringify(jsonRepr); - - // Do a flush if we can't add this record without exceeding our single-request - // limits, or without exceeding the total limit for a single batch. - let newLength = this.queued.length + bytes.length + 2; // extras for leading "[" / "," and trailing "]" - - let maxAllowedBytes = Math.min(256 * 1024, this.config.max_post_bytes); - - let postSizeExceeded = this.numQueued >= this.config.max_post_records || - newLength >= maxAllowedBytes; - - let batchSizeExceeded = (this.numQueued + this.numAlreadyBatched) >= this.config.max_batch_records || - (newLength + this.bytesAlreadyBatched) >= this.config.max_batch_bytes; - - let singleRecordTooBig = bytes.length + 2 > maxAllowedBytes; - - if (postSizeExceeded || batchSizeExceeded) { - this.log.trace(`PostQueue flushing due to postSizeExceeded=${postSizeExceeded}, batchSizeExceeded=${batchSizeExceeded}` + - `, max_batch_bytes: ${this.config.max_batch_bytes}, max_post_bytes: ${this.config.max_post_bytes}`); - - if (singleRecordTooBig) { - return { enqueued: false, error: new Error("Single record too large to submit to server") }; - } - - // We need to write the queue out before handling this one, but we only - // commit the batch (and thus start a new one) if the batch is full. - // Note that if a single record is too big for the batch or post, then - // the batch may be empty, and so we don't flush in that case. - if (this.numQueued) { - this.flush(batchSizeExceeded || singleRecordTooBig); - } - } - // Either a ',' or a '[' depending on whether this is the first record. - this.queued += this.numQueued ? "," : "["; - this.queued += bytes; - this.numQueued++; - return { enqueued: true }; - }, - - flush(finalBatchPost) { - if (!this.queued) { - // nothing queued - we can't be in a batch, and something has gone very - // bad if we think we are. - if (this.batchID) { - throw new Error(`Flush called when no queued records but we are in a batch ${this.batchID}`); - } - return; - } - // the batch query-param and headers we'll send. - let batch; - let headers = []; - if (this.batchID === undefined) { - // First commit in a (possible) batch. - batch = "true"; - } else if (this.batchID) { - // We have an existing batch. - batch = this.batchID; - } else { - // Not the first post and we know we have no batch semantics. - batch = null; - } - - headers.push(["x-if-unmodified-since", this.lastModified]); - - this.log.info(`Posting ${this.numQueued} records of ${this.queued.length+1} bytes with batch=${batch}`); - let queued = this.queued + "]"; - if (finalBatchPost) { - this.bytesAlreadyBatched = 0; - this.numAlreadyBatched = 0; - } else { - this.bytesAlreadyBatched += queued.length; - this.numAlreadyBatched += this.numQueued; - } - this.queued = ""; - this.numQueued = 0; - let response = this.poster(queued, headers, batch, !!(finalBatchPost && this.batchID !== null)); - - if (!response.success) { - this.log.trace("Server error response during a batch", response); - // not clear what we should do here - we expect the consumer of this to - // abort by throwing in the postCallback below. - return this.postCallback(response, !finalBatchPost); - } - - if (finalBatchPost) { - this.log.trace("Committed batch", this.batchID); - this.batchID = undefined; // we are now in "first post for the batch" state. - this.lastModified = response.headers["x-last-modified"]; - return this.postCallback(response, false); - } - - if (response.status != 202) { - if (this.batchID) { - throw new Error("Server responded non-202 success code while a batch was in progress"); - } - this.batchID = null; // no batch semantics are in place. - this.lastModified = response.headers["x-last-modified"]; - return this.postCallback(response, false); - } - - // this response is saying the server has batch semantics - we should - // always have a batch ID in the response. - let responseBatchID = response.obj.batch; - this.log.trace("Server responsed 202 with batch", responseBatchID); - if (!responseBatchID) { - this.log.error("Invalid server response: 202 without a batch ID", response); - throw new Error("Invalid server response: 202 without a batch ID"); - } - - if (this.batchID === undefined) { - this.batchID = responseBatchID; - if (!this.lastModified) { - this.lastModified = response.headers["x-last-modified"]; - if (!this.lastModified) { - throw new Error("Batch response without x-last-modified"); - } - } - } - - if (this.batchID != responseBatchID) { - throw new Error(`Invalid client/server batch state - client has ${this.batchID}, server has ${responseBatchID}`); - } - - this.postCallback(response, true); - }, -} diff --git a/services/sync/modules/resource.js b/services/sync/modules/resource.js index bf7066b9f..a6c0739b6 100644 --- a/services/sync/modules/resource.js +++ b/services/sync/modules/resource.js @@ -13,7 +13,6 @@ var Cr = Components.results; var Cu = Components.utils; Cu.import("resource://gre/modules/Preferences.jsm"); -Cu.import("resource://gre/modules/NetUtil.jsm"); Cu.import("resource://services-common/async.js"); Cu.import("resource://gre/modules/Log.jsm"); Cu.import("resource://services-common/observers.js"); @@ -74,6 +73,20 @@ AsyncResource.prototype = { */ authenticator: null, + // The string to use as the base User-Agent in Sync requests. + // These strings will look something like + // + // Firefox/4.0 FxSync/1.8.0.20100101.mobile + // + // or + // + // Firefox Aurora/5.0a1 FxSync/1.9.0.20110409.desktop + // + _userAgent: + Services.appinfo.name + "/" + Services.appinfo.version + // Product. + " FxSync/" + WEAVE_VERSION + "." + // Sync. + Services.appinfo.appBuildID + ".", // Build. + // Wait 5 minutes before killing a request. ABORT_TIMEOUT: 300000, @@ -121,9 +134,7 @@ AsyncResource.prototype = { // // Get and set the data encapulated in the resource. _data: null, - get data() { - return this._data; - }, + get data() this._data, set data(value) { this._data = value; }, @@ -135,9 +146,16 @@ AsyncResource.prototype = { // to obtain a request channel. // _createRequest: function Res__createRequest(method) { - let channel = NetUtil.newChannel({uri: this.spec, loadUsingSystemPrincipal: true}) - .QueryInterface(Ci.nsIRequest) - .QueryInterface(Ci.nsIHttpChannel); + let channel = Services.io.newChannel2(this.spec, + null, + null, + null, // aLoadingNode + Services.scriptSecurityManager.getSystemPrincipal(), + null, // aTriggeringPrincipal + Ci.nsILoadInfo.SEC_NORMAL, + Ci.nsIContentPolicy.TYPE_OTHER) + .QueryInterface(Ci.nsIRequest) + .QueryInterface(Ci.nsIHttpChannel); channel.loadFlags |= DEFAULT_LOAD_FLAGS; @@ -147,7 +165,8 @@ AsyncResource.prototype = { // Compose a UA string fragment from the various available identifiers. if (Svc.Prefs.get("sendVersionInfo", true)) { - channel.setRequestHeader("user-agent", Utils.userAgent, false); + let ua = this._userAgent + Svc.Prefs.get("client.type", "desktop"); + channel.setRequestHeader("user-agent", ua, false); } let headers = this.headers; @@ -209,10 +228,10 @@ AsyncResource.prototype = { this._log, this.ABORT_TIMEOUT); channel.requestMethod = action; try { - channel.asyncOpen2(listener); + channel.asyncOpen(listener, null); } catch (ex) { - // asyncOpen2 can throw in a bunch of cases -- e.g., a forbidden port. - this._log.warn("Caught an error in asyncOpen2", ex); + // asyncOpen can throw in a bunch of cases -- e.g., a forbidden port. + this._log.warn("Caught an error in asyncOpen: ", ex); CommonUtils.nextTick(callback.bind(this, ex)); } }, @@ -259,7 +278,8 @@ AsyncResource.prototype = { } catch(ex) { // Got a response, but an exception occurred during processing. // This shouldn't occur. - this._log.warn("Caught unexpected exception in _oncomplete", ex); + this._log.warn("Caught unexpected exception in _onComplete. ", ex); + this._log.debug(CommonUtils.stackTrace(ex)); } // Process headers. They can be empty, or the call can otherwise fail, so @@ -298,17 +318,14 @@ AsyncResource.prototype = { } } catch (ex) { this._log.debug("Caught exception visiting headers in _onComplete", ex); + this._log.debug(CommonUtils.stackTrace(ex)); } let ret = new String(data); - ret.url = channel.URI.spec; ret.status = status; ret.success = success; ret.headers = headers; - if (!success) { - this._log.warn(`${action} request to ${ret.url} failed with status ${status}`); - } // Make a lazy getter to convert the json response into an object. // Note that this can cause a parse error to be thrown far away from the // actual fetch, so be warned! @@ -384,12 +401,7 @@ Resource.prototype = { try { this._doRequest(action, data, callback); return Async.waitForSyncCallback(cb); - } catch (ex) { - if (Async.isShutdownException(ex)) { - throw ex; - } - this._log.warn("${action} request to ${url} failed: ${ex}", - { action, url: this.uri.spec, ex }); + } catch(ex) { // Combine the channel stack with this request stack. Need to create // a new error object for that. let error = Error(ex.message); @@ -543,9 +555,6 @@ ChannelListener.prototype = { try { this._onProgress(); } catch (ex) { - if (Async.isShutdownException(ex)) { - throw ex; - } this._log.warn("Got exception calling onProgress handler during fetch of " + req.URI.spec, ex); this._log.trace("Rethrowing; expect a failure code from the HTTP channel."); @@ -562,7 +571,7 @@ ChannelListener.prototype = { try { CommonUtils.namedTimer(this.abortRequest, this._timeout, this, "abortTimer"); } catch (ex) { - this._log.warn("Got exception extending abort timer", ex); + this._log.warn("Got exception extending abort timer: ", ex); } }, @@ -656,14 +665,14 @@ ChannelNotificationListener.prototype = { } } } catch (ex) { - this._log.error("Error copying headers", ex); + this._log.error("Error copying headers: ", ex); } // We let all redirects proceed. try { callback.onRedirectVerifyCallback(Cr.NS_OK); } catch (ex) { - this._log.error("onRedirectVerifyCallback threw!", ex); + this._log.error("onRedirectVerifyCallback threw!" + CommonUtils.exceptionStr(ex)); } } }; diff --git a/services/sync/modules/rest.js b/services/sync/modules/rest.js index 94c096dba..106ece222 100644 --- a/services/sync/modules/rest.js +++ b/services/sync/modules/rest.js @@ -28,6 +28,21 @@ SyncStorageRequest.prototype = { _logName: "Sync.StorageRequest", /** + * The string to use as the base User-Agent in Sync requests. + * These strings will look something like + * + * Firefox/4.0 FxSync/1.8.0.20100101.mobile + * + * or + * + * Firefox Aurora/5.0a1 FxSync/1.9.0.20110409.desktop + */ + userAgent: + Services.appinfo.name + "/" + Services.appinfo.version + // Product. + " FxSync/" + WEAVE_VERSION + "." + // Sync. + Services.appinfo.appBuildID + ".", // Build. + + /** * Wait 5 minutes before killing a request. */ timeout: STORAGE_REQUEST_TIMEOUT, @@ -35,7 +50,8 @@ SyncStorageRequest.prototype = { dispatch: function dispatch(method, data, onComplete, onProgress) { // Compose a UA string fragment from the various available identifiers. if (Svc.Prefs.get("sendVersionInfo", true)) { - this.setHeader("user-agent", Utils.userAgent); + let ua = this.userAgent + Svc.Prefs.get("client.type", "desktop"); + this.setHeader("user-agent", ua); } if (this.authenticator) { diff --git a/services/sync/modules/service.js b/services/sync/modules/service.js index 32e047f53..15884aca0 100644 --- a/services/sync/modules/service.js +++ b/services/sync/modules/service.js @@ -21,6 +21,7 @@ const KEYS_WBO = "keys"; Cu.import("resource://gre/modules/Preferences.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-common/utils.js"); Cu.import("resource://services-sync/constants.js"); Cu.import("resource://services-sync/engines.js"); Cu.import("resource://services-sync/engines/clients.js"); @@ -32,7 +33,6 @@ Cu.import("resource://services-sync/rest.js"); Cu.import("resource://services-sync/stages/enginesync.js"); Cu.import("resource://services-sync/stages/declined.js"); Cu.import("resource://services-sync/status.js"); -Cu.import("resource://services-sync/telemetry.js"); Cu.import("resource://services-sync/userapi.js"); Cu.import("resource://services-sync/util.js"); @@ -64,13 +64,8 @@ Sync11Service.prototype = { storageURL: null, metaURL: null, cryptoKeyURL: null, - // The cluster URL comes via the ClusterManager object, which in the FxA - // world is ebbedded in the token returned from the token server. - _clusterURL: null, - get serverURL() { - return Svc.Prefs.get("serverURL"); - }, + get serverURL() Svc.Prefs.get("serverURL"), set serverURL(value) { if (!value.endsWith("/")) { value += "/"; @@ -80,20 +75,14 @@ Sync11Service.prototype = { if (value == this.serverURL) return; + // A new server most likely uses a different cluster, so clear that Svc.Prefs.set("serverURL", value); - - // A new server most likely uses a different cluster, so clear that. - this._clusterURL = null; + Svc.Prefs.reset("clusterURL"); }, - get clusterURL() { - return this._clusterURL || ""; - }, + get clusterURL() Svc.Prefs.get("clusterURL", ""), set clusterURL(value) { - if (value != null && typeof value != "string") { - throw new Error("cluster must be a string, got " + (typeof value)); - } - this._clusterURL = value; + Svc.Prefs.set("clusterURL", value); this._updateCachedURLs(); }, @@ -171,16 +160,8 @@ Sync11Service.prototype = { _updateCachedURLs: function _updateCachedURLs() { // Nothing to cache yet if we don't have the building blocks - if (!this.clusterURL || !this.identity.username) { - // Also reset all other URLs used by Sync to ensure we aren't accidentally - // using one cached earlier - if there's no cluster URL any cached ones - // are invalid. - this.infoURL = undefined; - this.storageURL = undefined; - this.metaURL = undefined; - this.cryptoKeysURL = undefined; + if (!this.clusterURL || !this.identity.username) return; - } this._log.debug("Caching URLs under storage user base: " + this.userBaseURL); @@ -315,6 +296,21 @@ Sync11Service.prototype = { return false; }, + // The global "enabled" state comes from prefs, and will be set to false + // whenever the UI that exposes what to sync finds all Sync engines disabled. + get enabled() { + return Svc.Prefs.get("enabled"); + }, + set enabled(val) { + // There's no real reason to impose this other than to catch someone doing + // something we don't expect with bad consequences - all setting of this + // pref are in the UI code and external to this module. + if (val) { + throw new Error("Only disabling via this setter is supported"); + } + Svc.Prefs.set("enabled", val); + }, + /** * Prepare to initialize the rest of Weave after waiting a little bit */ @@ -344,8 +340,6 @@ Sync11Service.prototype = { this._clusterManager = this.identity.createClusterManager(this); this.recordManager = new RecordManager(this); - this.enabled = true; - this._registerEngines(); let ua = Cc["@mozilla.org/network/protocol;1?name=http"]. @@ -359,7 +353,6 @@ Sync11Service.prototype = { } Svc.Obs.add("weave:service:setup-complete", this); - Svc.Obs.add("sync:collection_changed", this); // Pulled from FxAccountsCommon Svc.Prefs.observe("engine.", this); this.scheduler = new SyncScheduler(this); @@ -472,7 +465,7 @@ Sync11Service.prototype = { this.engineManager.register(ns[engineName]); } catch (ex) { - this._log.warn("Could not register engine " + name, ex); + this._log.warn("Could not register engine " + name + ": ", ex); } } @@ -486,13 +479,6 @@ Sync11Service.prototype = { observe: function observe(subject, topic, data) { switch (topic) { - // Ideally this observer should be in the SyncScheduler, but it would require - // some work to know about the sync specific engines. We should move this there once it does. - case "sync:collection_changed": - if (data.includes("clients")) { - this.sync([]); // [] = clients collection only - } - break; case "weave:service:setup-complete": let status = this._checkSetup(); if (status != STATUS_DISABLED && status != CLIENT_NOT_CONFIGURED) @@ -557,8 +543,7 @@ Sync11Service.prototype = { // Always check for errors; this is also where we look for X-Weave-Alert. this.errorHandler.checkServerError(info); if (!info.success) { - this._log.error("Aborting sync: failed to get collections.") - throw info; + throw "Aborting sync: failed to get collections."; } return info; }, @@ -675,13 +660,20 @@ Sync11Service.prototype = { } catch (ex) { // This means no keys are present, or there's a network error. - this._log.debug("Failed to fetch and verify keys", ex); + this._log.debug("Failed to fetch and verify keys: ", ex); this.errorHandler.checkServerError(ex); return false; } }, verifyLogin: function verifyLogin(allow40XRecovery = true) { + // If the identity isn't ready it might not know the username... + if (!this.identity.readyToAuthenticate) { + this._log.info("Not ready to authenticate in verifyLogin."); + this.status.login = LOGIN_FAILED_NOT_READY; + return false; + } + if (!this.identity.username) { this._log.warn("No username in verifyLogin."); this.status.login = LOGIN_FAILED_NO_USERNAME; @@ -753,12 +745,8 @@ Sync11Service.prototype = { return this.verifyLogin(false); } - // We must have the right cluster, but the server doesn't expect us. - // The implications of this depend on the identity being used - for - // the legacy identity, it's an authoritatively "incorrect password", - // (ie, LOGIN_FAILED_LOGIN_REJECTED) but for FxA it probably means - // "transient error fetching auth token". - this.status.login = this.identity.loginStatusFromVerification404(); + // We must have the right cluster, but the server doesn't expect us + this.status.login = LOGIN_FAILED_LOGIN_REJECTED; return false; default: @@ -769,7 +757,7 @@ Sync11Service.prototype = { } } catch (ex) { // Must have failed on some network issue - this._log.debug("verifyLogin failed", ex); + this._log.debug("verifyLogin failed: ", ex); this.status.login = LOGIN_FAILED_NETWORK_ERROR; this.errorHandler.checkServerError(ex); return false; @@ -842,7 +830,7 @@ Sync11Service.prototype = { try { cb.wait(); } catch (ex) { - this._log.debug("Password change failed", ex); + this._log.debug("Password change failed: ", ex); return false; } @@ -888,7 +876,7 @@ Sync11Service.prototype = { try { engine.removeClientData(); } catch(ex) { - this._log.warn(`Deleting client data for ${engine.name} failed`, ex); + this._log.warn("Deleting client data for " + engine.name + " failed:", ex); } } this._log.debug("Finished deleting client data."); @@ -914,7 +902,6 @@ Sync11Service.prototype = { this._ignorePrefObserver = true; Svc.Prefs.resetBranch(""); this._ignorePrefObserver = false; - this.clusterURL = null; Svc.Prefs.set("lastversion", WEAVE_VERSION); @@ -931,22 +918,25 @@ Sync11Service.prototype = { return; } - try { - this.identity.finalize(); - // an observer so the FxA migration code can take some action before - // the new identity is created. - Svc.Obs.notify("weave:service:start-over:init-identity"); - this.identity.username = ""; - this.status.__authManager = null; - this.identity = Status._authManager; - this._clusterManager = this.identity.createClusterManager(this); - Svc.Obs.notify("weave:service:start-over:finish"); - } catch (err) { - this._log.error("startOver failed to re-initialize the identity manager: " + err); - // Still send the observer notification so the current state is - // reflected in the UI. - Svc.Obs.notify("weave:service:start-over:finish"); - } + this.identity.finalize().then( + () => { + // an observer so the FxA migration code can take some action before + // the new identity is created. + Svc.Obs.notify("weave:service:start-over:init-identity"); + this.identity.username = ""; + this.status.__authManager = null; + this.identity = Status._authManager; + this._clusterManager = this.identity.createClusterManager(this); + Svc.Obs.notify("weave:service:start-over:finish"); + } + ).then(null, + err => { + this._log.error("startOver failed to re-initialize the identity manager: " + err); + // Still send the observer notification so the current state is + // reflected in the UI. + Svc.Obs.notify("weave:service:start-over:finish"); + } + ); }, persistLogin: function persistLogin() { @@ -981,12 +971,8 @@ Sync11Service.prototype = { } // Ask the identity manager to explicitly login now. - this._log.info("Logging in the user."); let cb = Async.makeSpinningCallback(); - this.identity.ensureLoggedIn().then( - () => cb(null), - err => cb(err || "ensureLoggedIn failed") - ); + this.identity.ensureLoggedIn().then(cb, cb); // Just let any errors bubble up - they've more context than we do! cb.wait(); @@ -997,9 +983,9 @@ Sync11Service.prototype = { && (username || password || passphrase)) { Svc.Obs.notify("weave:service:setup-complete"); } + this._log.info("Logging in the user."); this._updateCachedURLs(); - this._log.info("User logged in successfully - verifying login."); if (!this.verifyLogin()) { // verifyLogin sets the failure states here. throw "Login failed: " + this.status.login; @@ -1064,49 +1050,11 @@ Sync11Service.prototype = { } }, - // Note: returns false if we failed for a reason other than the server not yet - // supporting the api. - _fetchServerConfiguration() { - if (Svc.Prefs.get("APILevel") >= 2) { - // This is similar to _fetchInfo, but with different error handling. - // Only supported by later sync implementations. - - let infoURL = this.userBaseURL + "info/configuration"; - this._log.debug("Fetching server configuration", infoURL); - let configResponse; - try { - configResponse = this.resource(infoURL).get(); - } catch (ex) { - // This is probably a network or similar error. - this._log.warn("Failed to fetch info/configuration", ex); - this.errorHandler.checkServerError(ex); - return false; - } - - if (configResponse.status == 404) { - // This server doesn't support the URL yet - that's OK. - this._log.debug("info/configuration returned 404 - using default upload semantics"); - } else if (configResponse.status != 200) { - this._log.warn(`info/configuration returned ${configResponse.status} - using default configuration`); - this.errorHandler.checkServerError(configResponse); - return false; - } else { - this.serverConfiguration = configResponse.obj; - } - this._log.trace("info/configuration for this server", this.serverConfiguration); - } - return true; - }, - // Stuff we need to do after login, before we can really do // anything (e.g. key setup). _remoteSetup: function _remoteSetup(infoResponse) { let reset = false; - if (!this._fetchServerConfiguration()) { - return false; - } - this._log.debug("Fetching global metadata record"); let meta = this.recordManager.get(this.metaURL); @@ -1133,7 +1081,7 @@ Sync11Service.prototype = { return false; } - if (this.recordManager.response.status == 404) { + if (!this.recordManager.response.success || !newMeta) { this._log.debug("No meta/global record on the server. Creating one."); newMeta = new WBORecord("meta", "global"); newMeta.payload.syncID = this.syncID; @@ -1143,16 +1091,10 @@ Sync11Service.prototype = { newMeta.isNew = true; this.recordManager.set(this.metaURL, newMeta); - let uploadRes = newMeta.upload(this.resource(this.metaURL)); - if (!uploadRes.success) { + if (!newMeta.upload(this.resource(this.metaURL)).success) { this._log.warn("Unable to upload new meta/global. Failing remote setup."); - this.errorHandler.checkServerError(uploadRes); return false; } - } else if (!newMeta) { - this._log.warn("Unable to get meta/global. Failing remote setup."); - this.errorHandler.checkServerError(this.recordManager.response); - return false; } else { // If newMeta, then it stands to reason that meta != null. newMeta.isNew = meta.isNew; @@ -1293,9 +1235,13 @@ Sync11Service.prototype = { return reason; }, - sync: function sync(engineNamesToSync) { - let dateStr = Utils.formatTimestamp(new Date()); - this._log.debug("User-Agent: " + Utils.userAgent); + sync: function sync() { + if (!this.enabled) { + this._log.debug("Not syncing as Sync is disabled."); + return; + } + let dateStr = new Date().toLocaleFormat(LOG_DATE_FORMAT); + this._log.debug("User-Agent: " + SyncStorageRequest.prototype.userAgent); this._log.info("Starting sync at " + dateStr); this._catch(function () { // Make sure we're logged in. @@ -1309,60 +1255,50 @@ Sync11Service.prototype = { else { this._log.trace("In sync: no need to login."); } - return this._lockedSync(engineNamesToSync); + return this._lockedSync.apply(this, arguments); })(); }, /** * Sync up engines with the server. */ - _lockedSync: function _lockedSync(engineNamesToSync) { + _lockedSync: function _lockedSync() { return this._lock("service.js: sync", this._notify("sync", "", function onNotify() { - let histogram = Services.telemetry.getHistogramById("WEAVE_START_COUNT"); - histogram.add(1); - let synchronizer = new EngineSynchronizer(this); let cb = Async.makeSpinningCallback(); synchronizer.onComplete = cb; - synchronizer.sync(engineNamesToSync); + synchronizer.sync(); // wait() throws if the first argument is truthy, which is exactly what // we want. let result = cb.wait(); - histogram = Services.telemetry.getHistogramById("WEAVE_COMPLETE_SUCCESS_COUNT"); - histogram.add(1); - // We successfully synchronized. // Check if the identity wants to pre-fetch a migration sentinel from // the server. - // Only supported by Sync server API level 2+ // If we have no clusterURL, we are probably doing a node reassignment // so don't attempt to get it in that case. - if (Svc.Prefs.get("APILevel") >= 2 && this.clusterURL) { - this.identity.prefetchMigrationSentinel(this); + //if (this.clusterURL) { + // this.identity.prefetchMigrationSentinel(this); + //} + + // Now let's update our declined engines. + let meta = this.recordManager.get(this.metaURL); + if (!meta) { + this._log.warn("No meta/global; can't update declined state."); + return; } - // Now let's update our declined engines (but only if we have a metaURL; - // if Sync failed due to no node we will not have one) - if (this.metaURL) { - let meta = this.recordManager.get(this.metaURL); - if (!meta) { - this._log.warn("No meta/global; can't update declined state."); - return; - } - - let declinedEngines = new DeclinedEngines(this); - let didChange = declinedEngines.updateDeclined(meta, this.engineManager); - if (!didChange) { - this._log.info("No change to declined engines. Not reuploading meta/global."); - return; - } - - this.uploadMetaGlobal(meta); + let declinedEngines = new DeclinedEngines(this); + let didChange = declinedEngines.updateDeclined(meta, this.engineManager); + if (!didChange) { + this._log.info("No change to declined engines. Not reuploading meta/global."); + return; } + + this.uploadMetaGlobal(meta); }))(); }, @@ -1385,92 +1321,6 @@ Sync11Service.prototype = { }, /** - * Get a migration sentinel for the Firefox Accounts migration. - * Returns a JSON blob - it is up to callers of this to make sense of the - * data. - * - * Returns a promise that resolves with the sentinel, or null. - */ - getFxAMigrationSentinel: function() { - if (this._shouldLogin()) { - this._log.debug("In getFxAMigrationSentinel: should login."); - if (!this.login()) { - this._log.debug("Can't get migration sentinel: login returned false."); - return Promise.resolve(null); - } - } - if (!this.identity.syncKeyBundle) { - this._log.error("Can't get migration sentinel: no syncKeyBundle."); - return Promise.resolve(null); - } - try { - let collectionURL = this.storageURL + "meta/fxa_credentials"; - let cryptoWrapper = this.recordManager.get(collectionURL); - if (!cryptoWrapper || !cryptoWrapper.payload) { - // nothing to decrypt - .decrypt is noisy in that case, so just bail - // now. - return Promise.resolve(null); - } - // If the payload has a sentinel it means we must have put back the - // decrypted version last time we were called. - if (cryptoWrapper.payload.sentinel) { - return Promise.resolve(cryptoWrapper.payload.sentinel); - } - // If decryption fails it almost certainly means the key is wrong - but - // it's not clear if we need to take special action for that case? - let payload = cryptoWrapper.decrypt(this.identity.syncKeyBundle); - // After decrypting the ciphertext is lost, so we just stash the - // decrypted payload back into the wrapper. - cryptoWrapper.payload = payload; - return Promise.resolve(payload.sentinel); - } catch (ex) { - this._log.error("Failed to fetch the migration sentinel: ${}", ex); - return Promise.resolve(null); - } - }, - - /** - * Set a migration sentinel for the Firefox Accounts migration. - * Accepts a JSON blob - it is up to callers of this to make sense of the - * data. - * - * Returns a promise that resolves with a boolean which indicates if the - * sentinel was successfully written. - */ - setFxAMigrationSentinel: function(sentinel) { - if (this._shouldLogin()) { - this._log.debug("In setFxAMigrationSentinel: should login."); - if (!this.login()) { - this._log.debug("Can't set migration sentinel: login returned false."); - return Promise.resolve(false); - } - } - if (!this.identity.syncKeyBundle) { - this._log.error("Can't set migration sentinel: no syncKeyBundle."); - return Promise.resolve(false); - } - try { - let collectionURL = this.storageURL + "meta/fxa_credentials"; - let cryptoWrapper = new CryptoWrapper("meta", "fxa_credentials"); - cryptoWrapper.cleartext.sentinel = sentinel; - - cryptoWrapper.encrypt(this.identity.syncKeyBundle); - - let res = this.resource(collectionURL); - let response = res.put(cryptoWrapper.toJSON()); - - if (!response.success) { - throw response; - } - this.recordManager.set(collectionURL, cryptoWrapper); - } catch (ex) { - this._log.error("Failed to set the migration sentinel: ${}", ex); - return Promise.resolve(false); - } - return Promise.resolve(true); - }, - - /** * If we have a passphrase, rather than a 25-alphadigit sync key, * use the provided sync ID to bootstrap it using PBKDF2. * @@ -1555,7 +1405,6 @@ Sync11Service.prototype = { */ wipeServer: function wipeServer(collections) { let response; - let histogram = Services.telemetry.getHistogramById("WEAVE_WIPE_SERVER_SUCCEEDED"); if (!collections) { // Strip the trailing slash. let res = this.resource(this.storageURL.slice(0, -1)); @@ -1563,17 +1412,14 @@ Sync11Service.prototype = { try { response = res.delete(); } catch (ex) { - this._log.debug("Failed to wipe server", ex); - histogram.add(false); + this._log.debug("Failed to wipe server: ", ex); throw ex; } if (response.status != 200 && response.status != 404) { this._log.debug("Aborting wipeServer. Server responded with " + response.status + " response for " + this.storageURL); - histogram.add(false); throw response; } - histogram.add(true); return response.headers["x-weave-timestamp"]; } @@ -1583,15 +1429,13 @@ Sync11Service.prototype = { try { response = this.resource(url).delete(); } catch (ex) { - this._log.debug("Failed to wipe '" + name + "' collection", ex); - histogram.add(false); + this._log.debug("Failed to wipe '" + name + "' collection: ", ex); throw ex; } if (response.status != 200 && response.status != 404) { this._log.debug("Aborting wipeServer. Server responded with " + response.status + " response for " + url); - histogram.add(false); throw response; } @@ -1599,7 +1443,7 @@ Sync11Service.prototype = { timestamp = response.headers["x-weave-timestamp"]; } } - histogram.add(true); + return timestamp; }, @@ -1623,7 +1467,7 @@ Sync11Service.prototype = { } // Fully wipe each engine if it's able to decrypt data - for (let engine of engines) { + for each (let engine in engines) { if (engine.canDecrypt()) { engine.wipeClient(); } @@ -1731,7 +1575,7 @@ Sync11Service.prototype = { return this.getStorageRequest(url).get(function onComplete(error) { // Note: 'this' is the request. if (error) { - this._log.debug("Failed to retrieve '" + info_type + "'", error); + this._log.debug("Failed to retrieve '" + info_type + "': ", error); return callback(error); } if (this.response.status != 200) { diff --git a/services/sync/modules/stages/cluster.js b/services/sync/modules/stages/cluster.js index 7665ce825..41afe61d8 100644 --- a/services/sync/modules/stages/cluster.js +++ b/services/sync/modules/stages/cluster.js @@ -80,9 +80,6 @@ ClusterManager.prototype = { return false; } - // Convert from the funky "String object with additional properties" that - // resource.js returns to a plain-old string. - cluster = cluster.toString(); // Don't update stuff if we already have the right cluster if (cluster == this.service.clusterURL) { return false; @@ -90,6 +87,7 @@ ClusterManager.prototype = { this._log.debug("Setting cluster to " + cluster); this.service.clusterURL = cluster; + Svc.Prefs.set("lastClusterUpdate", Date.now().toString()); return true; }, diff --git a/services/sync/modules/stages/enginesync.js b/services/sync/modules/stages/enginesync.js index a00a2f48b..61f2005d8 100644 --- a/services/sync/modules/stages/enginesync.js +++ b/services/sync/modules/stages/enginesync.js @@ -15,9 +15,6 @@ Cu.import("resource://services-sync/constants.js"); Cu.import("resource://services-sync/engines.js"); Cu.import("resource://services-sync/policies.js"); Cu.import("resource://services-sync/util.js"); -Cu.import("resource://services-common/observers.js"); -Cu.import("resource://services-common/async.js"); -Cu.import("resource://gre/modules/Task.jsm"); /** * Perform synchronization of engines. @@ -34,7 +31,7 @@ this.EngineSynchronizer = function EngineSynchronizer(service) { } EngineSynchronizer.prototype = { - sync: function sync(engineNamesToSync) { + sync: function sync() { if (!this.onComplete) { throw new Error("onComplete handler not installed."); } @@ -99,9 +96,6 @@ EngineSynchronizer.prototype = { return; } - // We only honor the "hint" of what engines to Sync if this isn't - // a first sync. - let allowEnginesHint = false; // Wipe data in the desired direction if necessary switch (Svc.Prefs.get("firstSync")) { case "resetClient": @@ -113,9 +107,6 @@ EngineSynchronizer.prototype = { case "wipeRemote": this.service.wipeRemote(engineManager.enabledEngineNames); break; - default: - allowEnginesHint = true; - break; } if (this.service.clientsEngine.localCommands) { @@ -145,31 +136,19 @@ EngineSynchronizer.prototype = { try { this._updateEnabledEngines(); } catch (ex) { - this._log.debug("Updating enabled engines failed", ex); + this._log.debug("Updating enabled engines failed: ", ex); this.service.errorHandler.checkServerError(ex); this.onComplete(ex); return; } - // If the engines to sync has been specified, we sync in the order specified. - let enginesToSync; - if (allowEnginesHint && engineNamesToSync) { - this._log.info("Syncing specified engines", engineNamesToSync); - enginesToSync = engineManager.get(engineNamesToSync).filter(e => e.enabled); - } else { - this._log.info("Syncing all enabled engines."); - enginesToSync = engineManager.getEnabled(); - } try { - // We don't bother validating engines that failed to sync. - let enginesToValidate = []; - for (let engine of enginesToSync) { + for (let engine of engineManager.getEnabled()) { // If there's any problems with syncing the engine, report the failure if (!(this._syncEngine(engine)) || this.service.status.enforceBackoff) { this._log.info("Aborting sync for failure in " + engine.name); break; } - enginesToValidate.push(engine); } // If _syncEngine fails for a 401, we might not have a cluster URL here. @@ -195,8 +174,6 @@ EngineSynchronizer.prototype = { } } - Async.promiseSpinningly(this._tryValidateEngines(enginesToValidate)); - // If there were no sync engine failures if (this.service.status.service != SYNC_FAILED_PARTIAL) { Svc.Prefs.set("lastSync", new Date().toString()); @@ -206,7 +183,7 @@ EngineSynchronizer.prototype = { Svc.Prefs.reset("firstSync"); let syncTime = ((Date.now() - startTime) / 1000).toFixed(2); - let dateStr = Utils.formatTimestamp(new Date()); + let dateStr = new Date().toLocaleFormat(LOG_DATE_FORMAT); this._log.info("Sync completed at " + dateStr + " after " + syncTime + " secs."); } @@ -214,106 +191,6 @@ EngineSynchronizer.prototype = { this.onComplete(null); }, - _tryValidateEngines: Task.async(function* (recentlySyncedEngines) { - if (!Services.telemetry.canRecordBase || !Svc.Prefs.get("validation.enabled", false)) { - this._log.info("Skipping validation: validation or telemetry reporting is disabled"); - return; - } - - let lastValidation = Svc.Prefs.get("validation.lastTime", 0); - let validationInterval = Svc.Prefs.get("validation.interval"); - let nowSeconds = Math.floor(Date.now() / 1000); - - if (nowSeconds - lastValidation < validationInterval) { - this._log.info("Skipping validation: too recent since last validation attempt"); - return; - } - // Update the time now, even if we may return false still. We don't want to - // check the rest of these more frequently than once a day. - Svc.Prefs.set("validation.lastTime", nowSeconds); - - // Validation only occurs a certain percentage of the time. - let validationProbability = Svc.Prefs.get("validation.percentageChance", 0) / 100.0; - if (validationProbability < Math.random()) { - this._log.info("Skipping validation: Probability threshold not met"); - return; - } - let maxRecords = Svc.Prefs.get("validation.maxRecords"); - if (!maxRecords) { - // Don't bother asking the server for the counts if we know validation - // won't happen anyway. - return; - } - - // maxRecords of -1 means "any number", so we can skip asking the server. - // Used for tests. - let info; - if (maxRecords < 0) { - info = {}; - for (let e of recentlySyncedEngines) { - info[e.name] = 1; // needs to be < maxRecords - } - maxRecords = 2; - } else { - - let collectionCountsURL = this.service.userBaseURL + "info/collection_counts"; - try { - let infoResp = this.service._fetchInfo(collectionCountsURL); - if (!infoResp.success) { - this._log.error("Can't run validation: request to info/collection_counts responded with " - + resp.status); - return; - } - info = infoResp.obj; // might throw because obj is a getter which parses json. - } catch (e) { - // Not running validation is totally fine, so we just write an error log and return. - this._log.error("Can't run validation: Caught error when fetching counts", e); - return; - } - } - - if (!info) { - return; - } - - let engineLookup = new Map(recentlySyncedEngines.map(e => [e.name, e])); - let toRun = []; - for (let [engineName, recordCount] of Object.entries(info)) { - let engine = engineLookup.get(engineName); - if (recordCount > maxRecords || !engine) { - this._log.debug(`Skipping validation for ${engineName} because it's not an engine or ` + - `the number of records (${recordCount}) is greater than the maximum allowed (${maxRecords}).`); - continue; - } - let validator = engine.getValidator(); - if (!validator) { - continue; - } - // Put this in an array so that we know how many we're going to do, so we - // don't tell users we're going to run some validators when we aren't. - toRun.push({ engine, validator }); - } - - if (!toRun.length) { - return; - } - Services.console.logStringMessage( - "Sync is about to run a consistency check. This may be slow, and " + - "can be controlled using the pref \"services.sync.validation.enabled\".\n" + - "If you encounter any problems because of this, please file a bug."); - for (let { validator, engine } of toRun) { - try { - let result = yield validator.validate(engine); - Observers.notify("weave:engine:validate:finish", result, engine.name); - } catch (e) { - this._log.error(`Failed to run validation on ${engine.name}!`, e); - Observers.notify("weave:engine:validate:error", e, engine.name) - // Keep validating -- there's no reason to think that a failure for one - // validator would mean the others will fail. - } - } - }), - // Returns true if sync should proceed. // false / no return value means sync should be aborted. _syncEngine: function _syncEngine(engine) { diff --git a/services/sync/modules/status.js b/services/sync/modules/status.js index 100bc7965..a5b8305b4 100644 --- a/services/sync/modules/status.js +++ b/services/sync/modules/status.js @@ -12,7 +12,6 @@ var Cu = Components.utils; Cu.import("resource://services-sync/constants.js"); Cu.import("resource://gre/modules/Log.jsm"); Cu.import("resource://services-sync/identity.js"); -Cu.import("resource://services-sync/browserid_identity.js"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://services-common/async.js"); @@ -28,9 +27,12 @@ this.Status = { let service = Components.classes["@mozilla.org/weave/service;1"] .getService(Components.interfaces.nsISupports) .wrappedJSObject; - let idClass = service.fxAccountsEnabled ? BrowserIDManager : IdentityManager; + let idClass = IdentityManager; this.__authManager = new idClass(); - this.__authManager.initialize(); + // .initialize returns a promise, so we need to spin until it resolves. + let cb = Async.makeSpinningCallback(); + this.__authManager.initialize().then(cb, cb); + cb.wait(); return this.__authManager; }, diff --git a/services/sync/modules/telemetry.js b/services/sync/modules/telemetry.js deleted file mode 100644 index c311387f7..000000000 --- a/services/sync/modules/telemetry.js +++ /dev/null @@ -1,578 +0,0 @@ -/* 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/. */ - -"use strict"; - -const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; - -this.EXPORTED_SYMBOLS = ["SyncTelemetry"]; - -Cu.import("resource://services-sync/browserid_identity.js"); -Cu.import("resource://services-sync/main.js"); -Cu.import("resource://services-sync/status.js"); -Cu.import("resource://services-sync/util.js"); -Cu.import("resource://services-common/observers.js"); -Cu.import("resource://services-common/async.js"); -Cu.import("resource://gre/modules/Log.jsm"); -Cu.import("resource://gre/modules/TelemetryController.jsm"); -Cu.import("resource://gre/modules/FxAccounts.jsm"); -Cu.import("resource://gre/modules/XPCOMUtils.jsm"); -Cu.import("resource://gre/modules/osfile.jsm", this); - -let constants = {}; -Cu.import("resource://services-sync/constants.js", constants); - -var fxAccountsCommon = {}; -Cu.import("resource://gre/modules/FxAccountsCommon.js", fxAccountsCommon); - -XPCOMUtils.defineLazyServiceGetter(this, "Telemetry", - "@mozilla.org/base/telemetry;1", - "nsITelemetry"); - -const log = Log.repository.getLogger("Sync.Telemetry"); - -const TOPICS = [ - "profile-before-change", - "weave:service:sync:start", - "weave:service:sync:finish", - "weave:service:sync:error", - - "weave:engine:sync:start", - "weave:engine:sync:finish", - "weave:engine:sync:error", - "weave:engine:sync:applied", - "weave:engine:sync:uploaded", - "weave:engine:validate:finish", - "weave:engine:validate:error", -]; - -const PING_FORMAT_VERSION = 1; - -// The set of engines we record telemetry for - any other engines are ignored. -const ENGINES = new Set(["addons", "bookmarks", "clients", "forms", "history", - "passwords", "prefs", "tabs", "extension-storage"]); - -// A regex we can use to replace the profile dir in error messages. We use a -// regexp so we can simply replace all case-insensitive occurences. -// This escaping function is from: -// https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions -const reProfileDir = new RegExp( - OS.Constants.Path.profileDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), - "gi"); - -function transformError(error, engineName) { - if (Async.isShutdownException(error)) { - return { name: "shutdownerror" }; - } - - if (typeof error === "string") { - if (error.startsWith("error.")) { - // This is hacky, but I can't imagine that it's not also accurate. - return { name: "othererror", error }; - } - // There's a chance the profiledir is in the error string which is PII we - // want to avoid including in the ping. - error = error.replace(reProfileDir, "[profileDir]"); - return { name: "unexpectederror", error }; - } - - if (error.failureCode) { - return { name: "othererror", error: error.failureCode }; - } - - if (error instanceof AuthenticationError) { - return { name: "autherror", from: error.source }; - } - - if (error instanceof Ci.mozIStorageError) { - return { name: "sqlerror", code: error.result }; - } - - let httpCode = error.status || - (error.response && error.response.status) || - error.code; - - if (httpCode) { - return { name: "httperror", code: httpCode }; - } - - if (error.result) { - return { name: "nserror", code: error.result }; - } - - return { - name: "unexpectederror", - // as above, remove the profile dir value. - error: String(error).replace(reProfileDir, "[profileDir]") - } -} - -function tryGetMonotonicTimestamp() { - try { - return Telemetry.msSinceProcessStart(); - } catch (e) { - log.warn("Unable to get a monotonic timestamp!"); - return -1; - } -} - -function timeDeltaFrom(monotonicStartTime) { - let now = tryGetMonotonicTimestamp(); - if (monotonicStartTime !== -1 && now !== -1) { - return Math.round(now - monotonicStartTime); - } - return -1; -} - -class EngineRecord { - constructor(name) { - // startTime is in ms from process start, but is monotonic (unlike Date.now()) - // so we need to keep both it and when. - this.startTime = tryGetMonotonicTimestamp(); - this.name = name; - } - - toJSON() { - let result = Object.assign({}, this); - delete result.startTime; - return result; - } - - finished(error) { - let took = timeDeltaFrom(this.startTime); - if (took > 0) { - this.took = took; - } - if (error) { - this.failureReason = transformError(error, this.name); - } - } - - recordApplied(counts) { - if (this.incoming) { - log.error(`Incoming records applied multiple times for engine ${this.name}!`); - return; - } - if (this.name === "clients" && !counts.failed) { - // ignore successful application of client records - // since otherwise they show up every time and are meaningless. - return; - } - - let incomingData = {}; - let properties = ["applied", "failed", "newFailed", "reconciled"]; - // Only record non-zero properties and only record incoming at all if - // there's at least one property we care about. - for (let property of properties) { - if (counts[property]) { - incomingData[property] = counts[property]; - this.incoming = incomingData; - } - } - } - - recordValidation(validationResult) { - if (this.validation) { - log.error(`Multiple validations occurred for engine ${this.name}!`); - return; - } - let { problems, version, duration, recordCount } = validationResult; - let validation = { - version: version || 0, - checked: recordCount || 0, - }; - if (duration > 0) { - validation.took = Math.round(duration); - } - let summarized = problems.getSummary(true).filter(({count}) => count > 0); - if (summarized.length) { - validation.problems = summarized; - } - this.validation = validation; - } - - recordValidationError(e) { - if (this.validation) { - log.error(`Multiple validations occurred for engine ${this.name}!`); - return; - } - - this.validation = { - failureReason: transformError(e) - }; - } - - recordUploaded(counts) { - if (counts.sent || counts.failed) { - if (!this.outgoing) { - this.outgoing = []; - } - this.outgoing.push({ - sent: counts.sent || undefined, - failed: counts.failed || undefined, - }); - } - } -} - -class TelemetryRecord { - constructor(allowedEngines) { - this.allowedEngines = allowedEngines; - // Our failure reason. This property only exists in the generated ping if an - // error actually occurred. - this.failureReason = undefined; - this.uid = ""; - this.when = Date.now(); - this.startTime = tryGetMonotonicTimestamp(); - this.took = 0; // will be set later. - - // All engines that have finished (ie, does not include the "current" one) - // We omit this from the ping if it's empty. - this.engines = []; - // The engine that has started but not yet stopped. - this.currentEngine = null; - } - - toJSON() { - let result = { - when: this.when, - uid: this.uid, - took: this.took, - failureReason: this.failureReason, - status: this.status, - deviceID: this.deviceID, - devices: this.devices, - }; - let engines = []; - for (let engine of this.engines) { - engines.push(engine.toJSON()); - } - if (engines.length > 0) { - result.engines = engines; - } - return result; - } - - finished(error) { - this.took = timeDeltaFrom(this.startTime); - if (this.currentEngine != null) { - log.error("Finished called for the sync before the current engine finished"); - this.currentEngine.finished(null); - this.onEngineStop(this.currentEngine.name); - } - if (error) { - this.failureReason = transformError(error); - } - - // We don't bother including the "devices" field if we can't come up with a - // UID or device ID for *this* device -- If that's the case, any data we'd - // put there would be likely to be full of garbage anyway. - let includeDeviceInfo = false; - try { - this.uid = Weave.Service.identity.hashedUID(); - let deviceID = Weave.Service.identity.deviceID(); - if (deviceID) { - // Combine the raw device id with the metrics uid to create a stable - // unique identifier that can't be mapped back to the user's FxA - // identity without knowing the metrics HMAC key. - this.deviceID = Utils.sha256(deviceID + this.uid); - includeDeviceInfo = true; - } - } catch (e) { - this.uid = "0".repeat(32); - this.deviceID = undefined; - } - - if (includeDeviceInfo) { - let remoteDevices = Weave.Service.clientsEngine.remoteClients; - this.devices = remoteDevices.map(device => { - return { - os: device.os, - version: device.version, - id: Utils.sha256(device.id + this.uid) - }; - }); - } - - // Check for engine statuses. -- We do this now, and not in engine.finished - // to make sure any statuses that get set "late" are recorded - for (let engine of this.engines) { - let status = Status.engines[engine.name]; - if (status && status !== constants.ENGINE_SUCCEEDED) { - engine.status = status; - } - } - - let statusObject = {}; - - let serviceStatus = Status.service; - if (serviceStatus && serviceStatus !== constants.STATUS_OK) { - statusObject.service = serviceStatus; - this.status = statusObject; - } - let syncStatus = Status.sync; - if (syncStatus && syncStatus !== constants.SYNC_SUCCEEDED) { - statusObject.sync = syncStatus; - this.status = statusObject; - } - } - - onEngineStart(engineName) { - if (this._shouldIgnoreEngine(engineName, false)) { - return; - } - - if (this.currentEngine) { - log.error(`Being told that engine ${engineName} has started, but current engine ${ - this.currentEngine.name} hasn't stopped`); - // Just discard the current engine rather than making up data for it. - } - this.currentEngine = new EngineRecord(engineName); - } - - onEngineStop(engineName, error) { - // We only care if it's the current engine if we have a current engine. - if (this._shouldIgnoreEngine(engineName, !!this.currentEngine)) { - return; - } - if (!this.currentEngine) { - // It's possible for us to get an error before the start message of an engine - // (somehow), in which case we still want to record that error. - if (!error) { - return; - } - log.error(`Error triggered on ${engineName} when no current engine exists: ${error}`); - this.currentEngine = new EngineRecord(engineName); - } - this.currentEngine.finished(error); - this.engines.push(this.currentEngine); - this.currentEngine = null; - } - - onEngineApplied(engineName, counts) { - if (this._shouldIgnoreEngine(engineName)) { - return; - } - this.currentEngine.recordApplied(counts); - } - - onEngineValidated(engineName, validationData) { - if (this._shouldIgnoreEngine(engineName, false)) { - return; - } - let engine = this.engines.find(e => e.name === engineName); - if (!engine && this.currentEngine && engineName === this.currentEngine.name) { - engine = this.currentEngine; - } - if (engine) { - engine.recordValidation(validationData); - } else { - log.warn(`Validation event triggered for engine ${engineName}, which hasn't been synced!`); - } - } - - onEngineValidateError(engineName, error) { - if (this._shouldIgnoreEngine(engineName, false)) { - return; - } - let engine = this.engines.find(e => e.name === engineName); - if (!engine && this.currentEngine && engineName === this.currentEngine.name) { - engine = this.currentEngine; - } - if (engine) { - engine.recordValidationError(error); - } else { - log.warn(`Validation failure event triggered for engine ${engineName}, which hasn't been synced!`); - } - } - - onEngineUploaded(engineName, counts) { - if (this._shouldIgnoreEngine(engineName)) { - return; - } - this.currentEngine.recordUploaded(counts); - } - - _shouldIgnoreEngine(engineName, shouldBeCurrent = true) { - if (!this.allowedEngines.has(engineName)) { - log.info(`Notification for engine ${engineName}, but we aren't recording telemetry for it`); - return true; - } - if (shouldBeCurrent) { - if (!this.currentEngine || engineName != this.currentEngine.name) { - log.error(`Notification for engine ${engineName} but it isn't current`); - return true; - } - } - return false; - } -} - -class SyncTelemetryImpl { - constructor(allowedEngines) { - log.level = Log.Level[Svc.Prefs.get("log.logger.telemetry", "Trace")]; - // This is accessible so we can enable custom engines during tests. - this.allowedEngines = allowedEngines; - this.current = null; - this.setupObservers(); - - this.payloads = []; - this.discarded = 0; - this.maxPayloadCount = Svc.Prefs.get("telemetry.maxPayloadCount"); - this.submissionInterval = Svc.Prefs.get("telemetry.submissionInterval") * 1000; - this.lastSubmissionTime = Telemetry.msSinceProcessStart(); - } - - getPingJSON(reason) { - return { - why: reason, - discarded: this.discarded || undefined, - version: PING_FORMAT_VERSION, - syncs: this.payloads.slice(), - }; - } - - finish(reason) { - // Note that we might be in the middle of a sync right now, and so we don't - // want to touch this.current. - let result = this.getPingJSON(reason); - this.payloads = []; - this.discarded = 0; - this.submit(result); - } - - setupObservers() { - for (let topic of TOPICS) { - Observers.add(topic, this, this); - } - } - - shutdown() { - this.finish("shutdown"); - for (let topic of TOPICS) { - Observers.remove(topic, this, this); - } - } - - submit(record) { - // We still call submit() with possibly illegal payloads so that tests can - // know that the ping was built. We don't end up submitting them, however. - if (record.syncs.length) { - log.trace(`submitting ${record.syncs.length} sync record(s) to telemetry`); - TelemetryController.submitExternalPing("sync", record); - } - } - - - onSyncStarted() { - if (this.current) { - log.warn("Observed weave:service:sync:start, but we're already recording a sync!"); - // Just discard the old record, consistent with our handling of engines, above. - this.current = null; - } - this.current = new TelemetryRecord(this.allowedEngines); - } - - _checkCurrent(topic) { - if (!this.current) { - log.warn(`Observed notification ${topic} but no current sync is being recorded.`); - return false; - } - return true; - } - - onSyncFinished(error) { - if (!this.current) { - log.warn("onSyncFinished but we aren't recording"); - return; - } - this.current.finished(error); - if (this.payloads.length < this.maxPayloadCount) { - this.payloads.push(this.current.toJSON()); - } else { - ++this.discarded; - } - this.current = null; - if ((Telemetry.msSinceProcessStart() - this.lastSubmissionTime) > this.submissionInterval) { - this.finish("schedule"); - this.lastSubmissionTime = Telemetry.msSinceProcessStart(); - } - } - - observe(subject, topic, data) { - log.trace(`observed ${topic} ${data}`); - - switch (topic) { - case "profile-before-change": - this.shutdown(); - break; - - /* sync itself state changes */ - case "weave:service:sync:start": - this.onSyncStarted(); - break; - - case "weave:service:sync:finish": - if (this._checkCurrent(topic)) { - this.onSyncFinished(null); - } - break; - - case "weave:service:sync:error": - // argument needs to be truthy (this should always be the case) - this.onSyncFinished(subject || "Unknown"); - break; - - /* engine sync state changes */ - case "weave:engine:sync:start": - if (this._checkCurrent(topic)) { - this.current.onEngineStart(data); - } - break; - case "weave:engine:sync:finish": - if (this._checkCurrent(topic)) { - this.current.onEngineStop(data, null); - } - break; - - case "weave:engine:sync:error": - if (this._checkCurrent(topic)) { - // argument needs to be truthy (this should always be the case) - this.current.onEngineStop(data, subject || "Unknown"); - } - break; - - /* engine counts */ - case "weave:engine:sync:applied": - if (this._checkCurrent(topic)) { - this.current.onEngineApplied(data, subject); - } - break; - - case "weave:engine:sync:uploaded": - if (this._checkCurrent(topic)) { - this.current.onEngineUploaded(data, subject); - } - break; - - case "weave:engine:validate:finish": - if (this._checkCurrent(topic)) { - this.current.onEngineValidated(data, subject); - } - break; - - case "weave:engine:validate:error": - if (this._checkCurrent(topic)) { - this.current.onEngineValidateError(data, subject || "Unknown"); - } - break; - - default: - log.warn(`unexpected observer topic ${topic}`); - break; - } - } -} - -this.SyncTelemetry = new SyncTelemetryImpl(ENGINES); diff --git a/services/sync/modules/util.js b/services/sync/modules/util.js index e9dbcb37d..12496d23a 100644 --- a/services/sync/modules/util.js +++ b/services/sync/modules/util.js @@ -35,6 +35,8 @@ this.Utils = { // In the ideal world, references to these would be removed. nextTick: CommonUtils.nextTick, namedTimer: CommonUtils.namedTimer, + exceptionStr: CommonUtils.exceptionStr, + stackTrace: CommonUtils.stackTrace, makeURI: CommonUtils.makeURI, encodeUTF8: CommonUtils.encodeUTF8, decodeUTF8: CommonUtils.decodeUTF8, @@ -52,7 +54,6 @@ this.Utils = { digestBytes: CryptoUtils.digestBytes, sha1: CryptoUtils.sha1, sha1Base32: CryptoUtils.sha1Base32, - sha256: CryptoUtils.sha256, makeHMACKey: CryptoUtils.makeHMACKey, makeHMACHasher: CryptoUtils.makeHMACHasher, hkdfExpand: CryptoUtils.hkdfExpand, @@ -61,25 +62,6 @@ this.Utils = { getHTTPMACSHA1Header: CryptoUtils.getHTTPMACSHA1Header, /** - * The string to use as the base User-Agent in Sync requests. - * This string will look something like - * - * Firefox/49.0a1 (Windows NT 6.1; WOW64; rv:46.0) FxSync/1.51.0.20160516142357.desktop - */ - _userAgent: null, - get userAgent() { - if (!this._userAgent) { - let hph = Cc["@mozilla.org/network/protocol;1?name=http"].getService(Ci.nsIHttpProtocolHandler); - this._userAgent = - Services.appinfo.name + "/" + Services.appinfo.version + // Product. - " (" + hph.oscpu + ")" + // (oscpu) - " FxSync/" + WEAVE_VERSION + "." + // Sync. - Services.appinfo.appBuildID + "."; // Build. - } - return this._userAgent + Svc.Prefs.get("client.type", "desktop"); - }, - - /** * Wrap a function to catch all exceptions and log them * * @usage MyObj._catch = Utils.catch; @@ -95,7 +77,7 @@ this.Utils = { return func.call(thisArg); } catch(ex) { - thisArg._log.debug("Exception calling " + (func.name || "anonymous function"), ex); + thisArg._log.debug("Exception: ", ex); if (exceptionCallback) { return exceptionCallback.call(thisArg, ex); } @@ -271,14 +253,14 @@ this.Utils = { */ base32ToFriendly: function base32ToFriendly(input) { return input.toLowerCase() - .replace(/l/g, '8') - .replace(/o/g, '9'); + .replace("l", '8', "g") + .replace("o", '9', "g"); }, base32FromFriendly: function base32FromFriendly(input) { return input.toUpperCase() - .replace(/8/g, 'L') - .replace(/9/g, 'O'); + .replace("8", 'L', "g") + .replace("9", 'O', "g"); }, /** @@ -411,52 +393,6 @@ this.Utils = { } }), - /** - * Move a json file in the profile directory. Will fail if a file exists at the - * destination. - * - * @returns a promise that resolves to undefined on success, or rejects on failure - * - * @param aFrom - * Current path to the JSON file saved on disk, relative to profileDir/weave - * .json will be appended to the file name. - * @param aTo - * New path to the JSON file saved on disk, relative to profileDir/weave - * .json will be appended to the file name. - * @param that - * Object to use for logging - */ - jsonMove(aFrom, aTo, that) { - let pathFrom = OS.Path.join(OS.Constants.Path.profileDir, "weave", - ...(aFrom + ".json").split("/")); - let pathTo = OS.Path.join(OS.Constants.Path.profileDir, "weave", - ...(aTo + ".json").split("/")); - if (that._log) { - that._log.trace("Moving " + pathFrom + " to " + pathTo); - } - return OS.File.move(pathFrom, pathTo, { noOverwrite: true }); - }, - - /** - * Removes a json file in the profile directory. - * - * @returns a promise that resolves to undefined on success, or rejects on failure - * - * @param filePath - * Current path to the JSON file saved on disk, relative to profileDir/weave - * .json will be appended to the file name. - * @param that - * Object to use for logging - */ - jsonRemove(filePath, that) { - let path = OS.Path.join(OS.Constants.Path.profileDir, "weave", - ...(filePath + ".json").split("/")); - if (that._log) { - that._log.trace("Deleting " + path); - } - return OS.File.remove(path, { ignoreAbsent: true }); - }, - getErrorString: function Utils_getErrorString(error, args) { try { return Str.errors.get(error, args || null); @@ -543,7 +479,7 @@ this.Utils = { // 20-char sync key. if (pp.length == 23 && - [5, 11, 17].every(i => pp[i] == '-')) { + [5, 11, 17].every(function(i) pp[i] == '-')) { return pp.slice(0, 5) + pp.slice(6, 11) + pp.slice(12, 17) + pp.slice(18, 23); @@ -551,7 +487,7 @@ this.Utils = { // "Modern" 26-char key. if (pp.length == 31 && - [1, 7, 13, 19, 25].every(i => pp[i] == '-')) { + [1, 7, 13, 19, 25].every(function(i) pp[i] == '-')) { return pp.slice(0, 1) + pp.slice(2, 7) + pp.slice(8, 13) + pp.slice(14, 19) @@ -681,12 +617,30 @@ this.Utils = { * Get the FxA identity hosts. */ getSyncCredentialsHostsFxA: function() { + // This is somewhat expensive and the result static, so we cache the result. + if (this._syncCredentialsHostsFxA) { + return this._syncCredentialsHostsFxA; + } let result = new Set(); // the FxA host result.add(FxAccountsCommon.FXA_PWDMGR_HOST); - // We used to include the FxA hosts (hence the Set() result) but we now - // don't give them special treatment (hence the Set() with exactly 1 item) - return result; + // + // The FxA hosts - these almost certainly all have the same hostname, but + // better safe than sorry... + for (let prefName of ["identity.fxaccounts.remote.force_auth.uri", + "identity.fxaccounts.remote.signup.uri", + "identity.fxaccounts.remote.signin.uri", + "identity.fxaccounts.settings.uri"]) { + let prefVal; + try { + prefVal = Services.prefs.getCharPref(prefName); + } catch (_) { + continue; + } + let uri = Services.io.newURI(prefVal, null, null); + result.add(uri.prePath); + } + return this._syncCredentialsHostsFxA = result; }, getDefaultDeviceName() { @@ -720,32 +674,6 @@ this.Utils = { Cc["@mozilla.org/network/protocol;1?name=http"].getService(Ci.nsIHttpProtocolHandler).oscpu; return Str.sync.get("client.name2", [user, appName, system]); - }, - - getDeviceName() { - const deviceName = Svc.Prefs.get("client.name", ""); - - if (deviceName === "") { - return this.getDefaultDeviceName(); - } - - return deviceName; - }, - - getDeviceType() { - return Svc.Prefs.get("client.type", DEVICE_TYPE_DESKTOP); - }, - - formatTimestamp(date) { - // Format timestamp as: "%Y-%m-%d %H:%M:%S" - let year = String(date.getFullYear()); - let month = String(date.getMonth() + 1).padStart(2, "0"); - let day = String(date.getDate()).padStart(2, "0"); - let hours = String(date.getHours()).padStart(2, "0"); - let minutes = String(date.getMinutes()).padStart(2, "0"); - let seconds = String(date.getSeconds()).padStart(2, "0"); - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } }; |