Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View file

@ -0,0 +1,103 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
/**
* See ../js/sync-engine/syncengine.js for an explanation of how DataAdapters
* are used by the SyncEngine.
*
* There needs to be one DataAdapter for each collection (history, passwords,
* bookmarks, tabs). If the user enabled syncing for a specific collection in
* Settings, the DataAdapter's `update` method will be called each time sync
* runs. The Kinto collection passed to `update` will contain a local copy of
* the remote data. It can be queried using its `list` and `get` methods.
*
* It is the task of the DataAdapter to compare this data with wherever the
* corresponding data lives on the device (e.g. the 'places' DataStore for
* history, the 'bookmarks_store' DataStore for bookmarks, etc.), and to update
* the device's live data based on the data in the Kinto collection.
*
* Subsequently, or at the same time, the DataAdapter should detect when data
* from the device needs to be added or changed in the Kinto collection, so that
* the SyncEngine can propagate these changes to the user's Firefox Sync
* account on the server.
* The update method should return a Promise for `true` if any changes were made
* to `kintoCollection` and a Promise for `false` otherwise. That way, the
* SyncEngine knows whether it's necessary to sync a second time, to upload such
* changes to the FxSync server.
*
* Before and after `update` is called, the SyncEngine synchronizes the Kinto
* collection with the remote server. In both cases, conflicts may occur. If
* they do, the `handleConflict` method on the DataAdapter is called once for
* each conflict, and has the opportunity to let the local copy win, let the
* remote copy win, or return a merged version of the two.
*/
'use strict';
/* exported ExampleAdapter */
var ExampleAdapter = {
/*
* update - Import incoming changes to the appropriate place on the device,
* and (optionally) export outgoing changes to SyncEngine.
*
* @param {Object} kintoCollection - A Kinto collection, see
* http://kintojs.readthedocs.org/en/latest/api/#collections
*
* @param {Object} options - either `{}` or `{ readonly: true }`
*
* @returns {Promise} A promise for a Boolean, indicating whether or not
* any outgoing changes were made that need to be synced up
* to FxSync.
*/
update: function(kintoCollection, options) {
// E.g. read all records from kintoCollection (last modified first):
kintoCollection.list().then(list => {
for(var i = 0; i < list.data.length; i++) {
console.log(`Record ${i}:`, list.data[i].payload);
}
});
if (options.readonly) {
// TODO: Import data from Kinto collection into the appropriate DataStore
// or Web API:
return Promise.resolve(false);
} else {
var kintoCollectionChanged = false;
// TODO: Sync changes both ways between Kinto collection and the
// appropriate DataStore or Web API.
// Whenever you use kintoCollection.add, kintoCollection.update,
// kintoCollection.delete, etc., set `kintoCollectionChanged` to true, and
// then:
return Promise.resolve(kintoCollectionChanged);
}
},
/*
* handleConflict - Decide how to resolve a Kinto conflict.
*
* @param {Object} conflict - A Kinto conflict, see
* http://kintojs.readthedocs.org/en/latest/api/#resolving-conflicts-manually
*
* @returns {Promise} A Promise for the desired resolution (could be
* conflict.local, conflict.remote, or a merge of the two).
*/
handleConflict: function(conflict) {
console.log('Example adapter handleConflict function called with:',
conflict.local,
conflict.remote);
return Promise.resolve(conflict.local);
},
/*
* reset - will be called in case of panic after an unrecoverable error, to
* reset any data the DataAdapter stores for efficiency of incremental
* consecutive sync runs (for instance, some DataAdapters will store
* the last modified time of unsynced records, and will only check
* records that were changed since then).
*/
reset: function(options) {
return Promise.resolve(`Reset check points for ${options.userid}.`);
}
};

View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Sync</title>
<meta charset="utf-8">
<!-- Lazy loaded shared
<script defer src="shared/js/async_storage.js"></script>
<script defer src="shared/js/sync/engine.js"></script>
<script defer src="shared/js/sync/errors.js"></script>
<script defer src="shared/js/sync/crypto/fxsyncwebcrypto.js"></script>
<script defer src="shared/js/sync/crypto/keyderivation.js"></script>
<script defer src="shared/js/sync/crypto/stringconversion.js"></script>
<script defer src="shared/js/sync/ext/kinto.min.js"></script>
-->
</head>
<body>
</body>
<script src="shared/js/dump.js"></script>
<script src="shared/js/lazy_loader.js"></script>
<script src="shared/js/iac_handler.js"></script>
<script src="shared/js/sync/bootstrap.js"></script>
</html>

View file

@ -0,0 +1,515 @@
/* 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/. */
/**
Bookmarks Data Adapter
The purpose of the Bookmarks adapter is to retrieve bookmark records from
Firefox Sync and merge them in a local bookmarks DataStore
('sync_bookmarks_store') which will be hosting Firefox Sync records only.
This adapter implements the API defined by SyncEngine. For now the
implementation allows only to retrieve bookmarks from Sync but it is not ready
for pushing any changes. The high level algorithm implemented here is the
following:
- Traverse all the records coming from Firefox Sync and stored locally
in Kinto.
- Convert any new bookmark from the Firefox Sync format to the
'sync_bookmarks_store' DataStore one.
- For existing bookmarks merge the remote with the local bookmark after
converting them to the proper 'sync_bookmarks_store' DS format.
- Remove any existing bookmark that has been remotely deleted (these records
come with a 'deleted: true' property).
**/
'use strict';
/* global
asyncStorage,
DataAdapters,
ERROR_SYNC_APP_RACE_CONDITION,
LazyLoader
*/
const BOOKMARKS_COLLECTION_MTIME = '::collections::bookmarks::mtime';
const BOOKMARKS_LAST_REVISIONID = '::collections::bookmarks::revisionid';
const BOOKMARKS_SYNCTOID_PREFIX = '::synctoid::bookmarks::';
var BookmarksHelper = (() => {
var _store;
function _ensureStore() {
if (_store) {
return Promise.resolve(_store);
}
return navigator.getDataStores('bookmarks_store').then(stores => {
_store = stores[0];
return _store;
});
}
/* SyncedCollectionMTime is the time of the last successful sync run.
* Subsequent sync runs will not check any records from the Kinto collection
* that have not been modified since then. This value is stored separately for
* each user (userid uniquely defines the FxSync account we're syncing with).
*/
function setSyncedCollectionMtime(mtime, userid) {
return new Promise(resolve => {
asyncStorage.setItem(userid + BOOKMARKS_COLLECTION_MTIME, mtime, resolve);
});
}
function removeSyncedCollectionMtime(userid) {
return new Promise(resolve =>
asyncStorage.removeItem(userid + BOOKMARKS_COLLECTION_MTIME, resolve));
}
function getSyncedCollectionMtime(userid) {
return new Promise(resolve => {
asyncStorage.getItem(userid + BOOKMARKS_COLLECTION_MTIME, resolve);
});
}
/* LastRevisionId is the revisionId the DataStore had at the beginning of the
* last sync run. Even though there is only one DataStore, it is stored once
* for each userid, because a sync run only syncs with the FxSync account of
* the currently logged in user.
*/
function getLastRevisionId(userid) {
return new Promise(resolve => {
asyncStorage.getItem(userid + BOOKMARKS_LAST_REVISIONID, resolve);
});
}
function setLastRevisionId(revisionId, userid) {
return new Promise(resolve => {
asyncStorage.setItem(userid + BOOKMARKS_LAST_REVISIONID, revisionId,
resolve);
});
}
function removeLastRevisionId(userid) {
return new Promise(resolve => {
asyncStorage.removeItem(userid + BOOKMARKS_LAST_REVISIONID, resolve);
});
}
/*
* setDataStoreId and getDataStoreId are used to create a table for caching
* SynctoId to DataStoreId matching. When a `deleted: true` record comes from
* FxSync, getDataStoreId can help to get DataStoreId easily. So a new record
* comes, the adapter has to use setDataStoreId to store the ID matching.
* Since both the synctoId and the dataStoreId for a given URL are unique to
* the currently logged in user, we store these values prefixed per `userid`
* (`xClientState` of the currently logged in user).
*/
function setDataStoreId(synctoId, dataStoreId, userid) {
return new Promise(resolve => {
asyncStorage.setItem(userid + BOOKMARKS_SYNCTOID_PREFIX + synctoId,
dataStoreId,
resolve);
});
}
function getDataStoreId(synctoId, userid) {
return new Promise(resolve => {
asyncStorage.getItem(userid + BOOKMARKS_SYNCTOID_PREFIX + synctoId,
resolve);
});
}
function mergeRecordsToDataStore(localRecord, remoteRecord, fxsyncId) {
if (!localRecord || !remoteRecord ||
localRecord.id !== remoteRecord.id ||
(remoteRecord.type === 'url' && localRecord.url !== remoteRecord.url)) {
// Local and remote records have different url(id).
console.error('Inconsistent records', localRecord, remoteRecord);
throw new Error('Inconsistent records');
}
localRecord.name = remoteRecord.name;
if (!localRecord.fxsyncRecords) {
localRecord.fxsyncRecords = {};
// We remember if a record had already been created locally before we got
// remote data for that URL, so that we know not to remove it even when
// the remote data is deleted. This applies only to readonly sync, and
// will be removed when sync becomes read-write.
localRecord.createdLocally = true;
}
localRecord.fxsyncRecords[fxsyncId] = remoteRecord.fxsyncRecords[fxsyncId];
return localRecord;
}
function addBookmark(remoteRecord, userid) {
// 1. Get bookmark by url(id of DataStore)
// 2.A. If the bookmark already exists locally,
// we merge it with the remote one.
// 2.B Add a new record with RevisionId.
// 3. Add the DataStore record ID into LocalID <-> RemoteID matching table.
var id = remoteRecord.id;
var revisionId;
return _ensureStore().then(store => {
revisionId = store.revisionId;
var fxsyncId = remoteRecord.fxsyncId;
delete remoteRecord.fxsyncId;
return store.get(id).then(localRecord => {
if (localRecord) {
var newBookmark = mergeRecordsToDataStore(localRecord, remoteRecord,
fxsyncId);
return store.put(newBookmark, id, revisionId);
}
// Setting createdLocally to false will cause the record to be deleted
// again if it's deleted remotely. This applies only to readonly sync,
// and will be removed when sync becomes read-write.
remoteRecord.createdLocally = false;
return store.add(remoteRecord, id, revisionId);
}).then(() => {
return setDataStoreId(fxsyncId, id, userid);
});
}).catch(e => {
if (e.name === 'ConstraintError' &&
e.message === 'RevisionId is not up-to-date') {
return LazyLoader.load(['shared/js/sync/errors.js']).then(() => {
throw new Error(ERROR_SYNC_APP_RACE_CONDITION);
});
}
console.error(e);
});
}
function deleteBookmark(fxsyncId, userid) {
var url;
return getDataStoreId(fxsyncId, userid).then(id => {
if (!id) {
console.warn('Ignoring incoming tombstone for unknown FxSyncID',
fxsyncId);
return Promise.resolve();
}
url = id;
return _ensureStore().then(store => {
var revisionId = store.revisionId;
return store.get(url).then(localRecord => {
localRecord.fxsyncRecords[fxsyncId] = {
deleted: true,
id: fxsyncId
};
var isEmpty = Object.keys(localRecord.fxsyncRecords).every(value => {
return localRecord.fxsyncRecords[value].deleted;
});
// Do not delete records that were originally created locally, even if
// they are deleted remotely. This applies only for readonly sync, and
// will be removed in the future when we switch to two-way sync.
if (isEmpty && !localRecord.createdLocally) {
return store.remove(url, revisionId);
} else {
return store.put(localRecord, url, revisionId);
}
});
});
});
}
function checkIfClearedSince(lastRevisionId, userid) {
return _ensureStore().then(store => {
if (lastRevisionId === null) {
var cursor = store.sync();
// Skip first task which is always { id: null, operation: 'clear' }
cursor.next().then(() => {
return cursor;
});
}
return store.sync(lastRevisionId);
}).then(cursor => {
var wasCleared = false;
return new Promise(resolve => {
function runNextTask(cursor) {
cursor.next().then(task => {
if (task.operation === 'done') {
resolve({
newRevisionId: task.revisionId,
wasCleared
});
} else {
// In readonly mode, if the DataStore was cleared, or some records
// were removed, it's possible that previously imported data was
// lost. Therefore, we return wasCleared: true after playing the
// DataStore history to its current revisionId, so that
// removeSyncedCollectionMtime will be called, and a full
// re-import is triggered.
// If only one record was removed then it would not be necessary
// to re-import the whole Kinto collection, but right now we have
// no efficient way to retrieve just one record from the Kinto
// collection based on URL, because we don't have a mapping from
// URL to fxsyncId. Since readonly sync is idempotent, there is
// not much harm in this, but it could possibly be made more
// efficient, see
// https://bugzilla.mozilla.org/show_bug.cgi?id=1223418.
if (['clear', 'remove'].indexOf(task.operation) !== -1) {
wasCleared = true;
}
// Avoid stack overflow:
setTimeout(() => {
// Will eventually get to a 'done' task:
runNextTask(cursor);
});
}
});
}
runNextTask(cursor);
});
});
}
/*
* handleClear - trigger re-import if DataStore was cleared
*
* In the future, the bookmarks and history DataAdapters will support
* two-way sync, so they will not only import data from the kinto.js
* collection into the DataStore, but also check what changes have
* been made recently in the DataStore, and reflect these in the kinto.js
* collection. Until then, the only thing we check from the DataStore is
* whether it has been cleared. If a `clear` operation was executed on the
* DataStore since the last time we checked (`lastRevisionId`), then the
* lastModifiedTime of the kinto.js collection is removed from asyncStorage,
* triggering a full import.
*/
function handleClear(userid) {
var newRevisionId;
return getLastRevisionId(userid).then(lastRevisionId => {
return checkIfClearedSince(lastRevisionId, userid);
}).then(result => {
newRevisionId = result.newRevisionId;
if(result.wasCleared) {
// Removing this from asyncStorage will trigger a full re-import.
return removeSyncedCollectionMtime(userid);
}
return Promise.resolve();
}).then(() => {
return setLastRevisionId(newRevisionId, userid);
});
}
function reset(userid) {
return Promise.all([
removeSyncedCollectionMtime(userid),
removeLastRevisionId(userid)
]);
}
return {
mergeRecordsToDataStore,
setSyncedCollectionMtime,
getSyncedCollectionMtime,
deleteBookmark,
addBookmark,
handleClear,
reset
};
})();
DataAdapters.bookmarks = {
/**
KintoCollection.list() provides a list containing all the remotely retrieved
Firefox Sync records sorted by "last_modified" property in descending order.
After each sync request we save the "last_modified" property of the last
processed record so we avoid going through the same records on following
operations.
Bookmark records are stored locally in a DataStore with format [1] while
bookmark records coming from Firefox Sync (via Kinto collection) have
format[2]. [4] is the detailed information for 6 different types defined in
FxSync BC. We need to convert from [1] to [2] and viceversa. Also, we need to
add the remote record ID[1.1] from the Kinto collection to its corresponding
match in the local DataStore, so we can remove the local record afterwards
when any deleting record requests with the format [3] are coming from FxSync.
[1] Records stored in Bookmarks DataStore (BDS): {
"id": "http://mozilla.org/", // KEY in PDS
"url": "http://mozilla.org/",
"name": "Mozilla",
"type": "url",
"iconable": false,
"icon": "http://www.lego.com/favicon.ico",
"fxsyncRecords": {
'fxsyncID_A': fxsync_payload_A,
'fxsyncID_B': fxsync_payload_B,
'fxsyncID_C': fxsync_payload_C
} // payload from BC
}
[2] Add/Update Records from Bookmark Collection (BC): {
"id": "zMgfGkRinh92",
"sortindex": 2000,
"last_modified": 1442247272150,
"payload": {
"id": "zMgfGkRinh92",
"type": "bookmark",
"title": "Mozilla",
"parentName": "mobile",
"bmkUri": "http://mozilla.org/",
"tags": [],
"keyword": null,
"description": null,
"loadInSidebar": false,
"parentid": "mobile"
},
"_status": "synced"
}
[3] Delete Records from Bookmark Collection (BC): {
"id": "_Avscjx5srFy",
"sortindex": 100,
"last_modified": 1441985077970,
"payload": {
"id": "_Avscjx5srFy",
"deleted": true
},
"_status": "synced"
}
[4] The schema table for 6 different types from [5]:
+---------------+---------------+---------------+
| bookmark | microsummary**| query |
+---------------+---------------+---------------+
| | *generatorUri | *folderName |
| | *staticTitle | *queryId |
| title | title | title |
| bmkUri | bmkUri | bmkUri |
| description | description | description |
| loadInSidebar | loadInSidebar | loadInSidebar |
| tags | tags | tags |
| keyword | keyword | keyword |
| parentid | parentid | parentid |
| parentName | parentName | parentName |
| predecessorid | predecessorid | predecessorid |
| type | type | type |
+---------------+---------------+---------------+
+---------------+---------------+---------------+
| folder | livemark | separator |
+---------------+---------------+---------------+
| | *siteUri | *pos |
| | *feedUri | |
| title | title | |
| parentid | parentid | parentid |
| parentName | parentName | parentName |
| predecessorid | predecessorid | predecessorid |
| type | type | type |
+---------------+---------------+---------------+
[*] Special property for its type.
[**] microsummary is OBSOLETED. Please see here:
https://wiki.mozilla.org/Microsummaries
[5] https://docs.services.mozilla.com/sync/objectformats.html#bookmarks
**/
_updateBookmark(payload, last_modified, userid) {
if (payload.type === 'microsummary') {
console.warn('microsummary is OBSOLETED ', payload);
return Promise.resolve();
}
if (payload.deleted) {
return BookmarksHelper.deleteBookmark(payload.id, userid);
} else if (['query', 'bookmark', 'folder', 'livemark', 'separator']
.every(value => value !== payload.type)) {
console.error('Unknown type? ', payload);
return Promise.resolve();
}
var typeWithUri = ['query', 'bookmark']
.some(value => value === payload.type);
if (typeWithUri && !payload.bmkUri) {
console.warn('Incorrect payload? ', payload);
return Promise.resolve();
}
var fxsyncRecords = {};
fxsyncRecords[payload.id] = payload;
fxsyncRecords[payload.id].timestamp = last_modified;
return BookmarksHelper.addBookmark({
// URL is the ID for bookmark records in bookmarks_store, but there are
// some types without a valid URL except bookmark type. URL is used as
// its ID to compatible bookmarks_store for bookmark type record.
// The combination of type and fxsyncID is used as its ID for the types
// except bookmark.
id: payload.type === 'bookmark' ? payload.bmkUri :
(payload.type + '|' + payload.id),
url: payload.bmkUri,
name: payload.title,
type: payload.type === 'bookmark' ? 'url' : 'others',
iconable: false,
icon: '',
fxsyncRecords: fxsyncRecords,
fxsyncId: payload.id
}, userid);
},
_next(remoteRecords, lastModifiedTime, userid, cursor) {
if (cursor === remoteRecords.length) {
return Promise.resolve();
}
if (!Number.isInteger(remoteRecords[cursor].last_modified)) {
console.warn('Incorrect last_modified?', remoteRecords[cursor]);
return this._next(remoteRecords, lastModifiedTime, userid, cursor + 1);
}
if (remoteRecords[cursor].last_modified <= lastModifiedTime) {
return Promise.resolve();
}
return this._updateBookmark(remoteRecords[cursor].payload,
remoteRecords[cursor].last_modified, userid).then(() => {
return this._next(remoteRecords, lastModifiedTime, userid, cursor + 1);
});
},
update(remoteBookmarks, options = { readonly: true }) {
if (!options.readonly) {
console.warn('Two-way sync not implemented yet for bookmarks.');
}
var mtime;
return LazyLoader.load(['shared/js/async_storage.js']).then(() => {
// We iterate over the records in the Kinto collection until we find a
// record whose last modified time is older than the time of the last
// successful sync run. However, if the DataStore has been cleared, or
// records have been removed from the DataStore since the last sync run,
// we cannot be sure that all older records are still there. So in both
// those cases we remove the SyncedCollectionMtime from AsyncStorage, so
// that this sync run will iterate over all the records in the Kinto
// collection, and not only over the ones that were recently modified.
return BookmarksHelper.handleClear(options.userid);
}).then(() => {
return BookmarksHelper.getSyncedCollectionMtime(options.userid);
}).then(_mtime => {
mtime = _mtime;
return remoteBookmarks.list();
}).then(list => {
return this._next(list.data, mtime, options.userid, 0).then(() => {
if (list.data.length === 0) {
return Promise.resolve();
}
var latestMtime = list.data[0].last_modified;
return BookmarksHelper.setSyncedCollectionMtime(latestMtime,
options.userid);
});
}).then(() => {
// Always return false for a read-only operation.
return Promise.resolve(false /* no writes done into kinto */);
}).catch(err => {
console.error('Bookmarks DataAdapter update error', err.message);
throw err;
});
},
handleConflict(conflict) {
// Because Bookmark adapter has not implemented record push yet,
// handleConflict will always use remote records.
return Promise.resolve(conflict.remote);
},
reset(options) {
return LazyLoader.load(['shared/js/async_storage.js']).then(() => {
return BookmarksHelper.reset(options.userid);
});
}
};

View file

@ -0,0 +1,479 @@
/* 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/. */
/**
History Data Adapter
The feature of History adapter is to retrieve History records from FxSync
Service and merge to Places DataStore which has the same functionality, and
the adapter is based on SyncEngine's design to handle 'update' and
'handleConflict' request. History Data Adapter provides Read-Only version
currently, and the basic ideas are included:
- Traverse all records from History Collection (HC).
- Add a new or existed place to Places DataStore (PDS).
- Convert the records from HC to PDS format.
- Merge a new record and the existed one then write to PDS.
- Remove an existed place when receiving a "deleted: true" record.
**/
'use strict';
/* global
asyncStorage,
DataAdapters,
ERROR_SYNC_APP_RACE_CONDITION,
LazyLoader
*/
const HISTORY_COLLECTION_MTIME = '::collections::history::mtime';
const HISTORY_LAST_REVISIONID = '::collections::history::revisionid';
const HISTORY_SYNCTOID_PREFIX = '::synctoid::history::';
var HistoryHelper = (() => {
var _store;
function _ensureStore() {
if (_store) {
return Promise.resolve(_store);
}
return navigator.getDataStores('places').then(stores => {
_store = stores[0];
return _store;
});
}
/* SyncedCollectionMTime is the time of the last successful sync run.
* Subsequent sync runs will not check any records from the Kinto collection
* that have not been modified since then. This value is stored separately for
* each user (userid uniquely defines the FxSync account we're syncing with).
*/
function setSyncedCollectionMtime(mtime, userid) {
return new Promise(resolve => {
asyncStorage.setItem(userid + HISTORY_COLLECTION_MTIME, mtime, resolve);
});
}
function removeSyncedCollectionMtime(userid) {
return new Promise(resolve => {
asyncStorage.removeItem(userid + HISTORY_COLLECTION_MTIME, resolve);
});
}
function getSyncedCollectionMtime(userid) {
return new Promise(resolve => {
asyncStorage.getItem(userid + HISTORY_COLLECTION_MTIME, resolve);
});
}
/* LastRevisionId is the revisionId the DataStore had at the beginning of the
* last sync run. Even though there is only one DataStore, it is stored once
* for each userid, because a sync run only syncs with the FxSync account of
* the currently logged in user.
*/
function getLastRevisionId(userid) {
return new Promise(resolve => {
asyncStorage.getItem(userid + HISTORY_LAST_REVISIONID, resolve);
});
}
function setLastRevisionId(lastRevisionId, userid) {
return new Promise(resolve => {
asyncStorage.setItem(userid + HISTORY_LAST_REVISIONID, lastRevisionId,
resolve);
});
}
function removeLastRevisionId(userid) {
return new Promise(resolve => {
asyncStorage.removeItem(userid + HISTORY_LAST_REVISIONID, resolve);
});
}
/*
* setDataStoreId and getDataStoreId are used to create a table for caching
* SynctoId to DataStoreId matching. When a `deleted: true` record comes from
* FxSync, getDataStoreId can help to get DataStoreId easily. So a new record
* comes, the adapter has to use setDataStoreId to store the ID matching.
* Since both the synctoId and the dataStoreId for a given URL are unique to
* the currently logged in user, we store these values prefixed per `userid`
* (`xClientState` of the currently logged in user).
*/
function setDataStoreId(synctoId, dataStoreId, userid) {
return new Promise(resolve => {
asyncStorage.setItem(userid + HISTORY_SYNCTOID_PREFIX + synctoId,
dataStoreId,
resolve);
});
}
function getDataStoreId(synctoId, userid) {
return new Promise(resolve => {
asyncStorage.getItem(userid + HISTORY_SYNCTOID_PREFIX + synctoId,
resolve);
});
}
function mergeRecordsToDataStore(localRecord, remoteRecord) {
if (!localRecord || !remoteRecord ||
localRecord.url !== remoteRecord.url) {
// The local record has different url(id) with the new one.
console.error('Inconsistent records on url', localRecord, remoteRecord);
throw new Error('Inconsistent records on url');
}
if (!localRecord.fxsyncId && typeof remoteRecord.fxsyncId === 'string') {
/* When a localRecord is existed without fxsyncId, assign fxsyncId to it
from a remoteRecord. This case always happens at first synchronization
or merging two records with the same URL. */
localRecord.fxsyncId = remoteRecord.fxsyncId;
} else if (localRecord.fxsyncId !== remoteRecord.fxsyncId) {
// Two records have different fxsyncId but have the same url(id).
console.log('Inconsistent records on FxSync ID',
localRecord, remoteRecord);
throw new Error('Inconsistent records on FxSync ID',
localRecord, remoteRecord);
}
// We remember if a record had already been created locally before we got
// remote data for that URL, so that we know not to remove it even when the
// remote data is deleted. This applies only to readonly sync, and will be
// removed when sync becomes read-write.
if (localRecord.createdLocally === undefined) {
localRecord.createdLocally = true;
}
localRecord.visits = localRecord.visits || [];
// If a localRecord is without any visit records or with older visit
// than remoteRecord, its title will be replaced by remoteRecord's.
if ((localRecord.visits.length === 0 && remoteRecord.title) ||
(remoteRecord.visits[0] >= localRecord.visits[0])) {
localRecord.title = remoteRecord.title;
}
remoteRecord.visits.forEach(item => {
if (localRecord.visits.indexOf(item) === -1) {
localRecord.visits.push(item);
}
});
localRecord.visits.sort((a, b) => {
// sort in descending order
return b - a;
});
return localRecord;
}
function addPlace(place, userid) {
// 1. Get place by url(id of DataStore)
// 2.A Merge the existing one and new one if it's an existing one,
// and update the places.
// 2.B Add a new record with RevisionId.
// 3. Add the DataStore record ID into LocalID <-> RemoteID matching table.
var id = place.url;
var revisionId;
return _ensureStore().then(placesStore => {
revisionId = placesStore.revisionId;
return placesStore.get(id).then(existedPlace => {
// Bug 1208352 - PlacesDS accessing code should be extracted to a shared
// code to prevent drifting out of sync from different piece codes.
if (existedPlace) {
var newPlace = mergeRecordsToDataStore(existedPlace, place);
return placesStore.put(newPlace, id, revisionId);
}
// Setting createdLocally to false will cause the record to be deleted
// again if it's deleted remotely. This applies only to readonly sync,
// and will be removed when sync becomes read-write.
place.createdLocally = false;
return placesStore.add(place, id, revisionId);
}).then(() => {
return setDataStoreId(place.fxsyncId, id, userid);
});
}).catch(e => {
if (e.name === 'ConstraintError' &&
e.message === 'RevisionId is not up-to-date') {
return LazyLoader.load(['shared/js/sync/errors.js']).then(() => {
throw new Error(ERROR_SYNC_APP_RACE_CONDITION);
});
}
console.error(e);
});
}
function deleteByDataStoreId(id) {
return _ensureStore().then(store => {
var revisionId = store.revisionId;
return store.get(id).then(record => {
// Do not delete records that were originally created locally, even if
// they are deleted remotely. This applies only for readonly sync, and
// will be removed in the future when we switch to two-way sync.
if (record.createdLocally) {
return Promise.resolve();
}
return store.remove(id, revisionId);
});
});
}
function deletePlace(fxsyncId, userid) {
return getDataStoreId(fxsyncId, userid).then(id => {
if (!id) {
console.warn('Ignoring incoming tombstone for unknown FxSyncID',
fxsyncId);
return Promise.resolve();
}
return deleteByDataStoreId(id);
});
}
function checkIfClearedSince(lastRevisionId, userid) {
return _ensureStore().then(store => {
if (lastRevisionId === null) {
var cursor = store.sync();
// Skip first task which is always { id: null, operation: 'clear' }
cursor.next().then(() => {
return cursor;
});
}
return store.sync(lastRevisionId);
}).then(cursor => {
var wasCleared = false;
return new Promise(resolve => {
function runNextTask(cursor) {
cursor.next().then(task => {
if (task.operation === 'done') {
resolve({
newRevisionId: task.revisionId,
wasCleared
});
} else {
// In readonly mode, if the DataStore was cleared, or some records
// were removed, it's possible that previously imported data was
// lost. Therefore, we return wasCleared: true after playing the
// DataStore history to its current revisionId, so that
// removeSyncedCollectionMtime will be called, and a full
// re-import is triggered.
// If only one record was removed then it would not be necessary
// to re-import the whole Kinto collection, but right now we have
// no efficient way to retrieve just one record from the Kinto
// collection based on URL, because we don't have a mapping from
// URL to fxsyncId. Since readonly sync is idempotent, there is
// not much harm in this, but it could possibly be made more
// efficient, see
// https://bugzilla.mozilla.org/show_bug.cgi?id=1223418.
if (['clear', 'remove'].indexOf(task.operation) !== -1) {
wasCleared = true;
}
// Avoid stack overflow:
setTimeout(() => {
// Will eventually get to a 'done' task:
runNextTask(cursor);
});
}
});
}
runNextTask(cursor);
});
});
}
/*
* handleClear - trigger re-import if DataStore was cleared
*
* In the future, the bookmarks and history DataAdapters will support
* two-way sync, so they will not only import data from the kinto.js
* collection into the DataStore, but also check what changes have
* been made recently in the DataStore, and reflect these in the kinto.js
* collection. Until then, the only thing we check from the DataStore is
* whether it has been cleared. If a `clear` operation was executed on the
* DataStore since the last time we checked (`lastRevisionId`), then the
* lastModifiedTime of the kinto.js collection is removed from asyncStorage,
* triggering a full import.
*/
function handleClear(userid) {
var newRevisionId;
return getLastRevisionId(userid).then(lastRevisionId => {
return checkIfClearedSince(lastRevisionId, userid);
}).then(result => {
newRevisionId = result.newRevisionId;
if(result.wasCleared) {
// Removing this from asyncStorage will trigger a full re-import.
return removeSyncedCollectionMtime(userid);
}
return Promise.resolve();
}).then(() => {
return setLastRevisionId(newRevisionId, userid);
});
}
function reset(userid) {
return Promise.all([
removeSyncedCollectionMtime(userid),
removeLastRevisionId(userid)
]);
}
return {
mergeRecordsToDataStore,
setSyncedCollectionMtime,
getSyncedCollectionMtime,
deletePlace,
deleteByDataStoreId,
addPlace,
handleClear,
reset
};
})();
DataAdapters.history = {
/**
KintoCollection.list() provides a list containing all the remotely retrieved
Firefox Sync records sorted by "last_modified" property in descending order.
After each sync request we save the "last_modified" property of the last
processed record so we avoid going through the same records on following
operations.
History records are stored locally in a DataStore with format [1] while
history records coming from Firefox Sync (via Kinto collection) have
format[2]. We need to convert from [1] to [2] and viceversa. Also, we need to
add the remote record ID[1.1] from the Kinto collection to its corresponding
match in the local DataStore, so we can remove the local record afterwards
when any deleting record requests with the format [3] are coming from FxSync.
[1] Records stored in Places DataStore (PDS): {
"url": "http://mozilla.org/", // KEY in PDS
"title": "Mozilla",
"icons": {
"http://mozilla.org/favicon.ico": {
"sizes": []
}
},
"frecency": 1,
"visits": [
// NOTICE: date/time without ms
1442247252490, 1442247250001
],
"screenshot": {},
"fxsyncId" "REMOTE_ID", // [1.1]
"visited": 1442247252490
}
[2] Add/Update Records from History Collection (HC): {
"id": "zMgfGkRinh92",
"sortindex": 2000,
"last_modified": 1442247272150,
"payload": {
"id": "zMgfGkRinh92",
"histUri": "http://mozilla.org/",
"title": "Mozilla",
"visits": [
// NOTICE: date/time with ms
{ "date": 1442247252490018, "type": 2 },
{ "date": 1442247250001234, "type": 2 }
]
},
"_status": "synced"
}
[3] Delete Records from History Collection (HC): {
"id": "_Avscjx5srFy",
"sortindex": 100,
"last_modified": 1441985077970,
"payload": {
"id": "_Avscjx5srFy",
"deleted": true
},
"_status": "synced"
}
**/
_updatePlace(payload, userid) {
if (payload.deleted) {
return HistoryHelper.deletePlace(payload.id, userid);
}
if (!payload.histUri || !payload.visits) {
console.warn('Incorrect payload?', payload);
return Promise.resolve();
}
if (payload.histUri && Array.isArray(payload.visits) &&
payload.visits.length === 0) {
return HistoryHelper.deleteByDataStoreId(payload.histUri);
}
return HistoryHelper.addPlace({
url: payload.histUri,
title: payload.title,
visits: payload.visits.map(elem => Math.floor(elem.date / 1000)),
fxsyncId: payload.id
}, userid);
},
_next(remoteRecords, lastModifiedTime, userid, cursor) {
if (cursor === remoteRecords.length) {
return Promise.resolve();
}
if (!Number.isInteger(remoteRecords[cursor].last_modified)) {
console.warn('Incorrect last_modified?', remoteRecords[cursor]);
return this._next(remoteRecords, lastModifiedTime, userid, cursor + 1);
}
if (remoteRecords[cursor].last_modified <= lastModifiedTime) {
return Promise.resolve();
}
return this._updatePlace(remoteRecords[cursor].payload, userid)
.then(() => {
return this._next(remoteRecords, lastModifiedTime, userid, cursor + 1);
});
},
update(remoteHistory, options = { readonly: true }) {
if (!options.readonly) {
console.warn('Two-way sync not implemented yet for history.');
}
var mtime;
return LazyLoader.load(['shared/js/async_storage.js']).then(() => {
// We iterate over the records in the Kinto collection until we find a
// record whose last modified time is older than the time of the last
// successful sync run. However, if the DataStore has been cleared, or
// records have been removed from the DataStore since the last sync run,
// we cannot be sure that all older records are still there. So in both
// those cases we remove the SyncedCollectionMtime from AsyncStorage, so
// that this sync run will iterate over all the records in the Kinto
// collection, and not only over the ones that were recently modified.
return HistoryHelper.handleClear(options.userid);
}).then(() => {
return HistoryHelper.getSyncedCollectionMtime(options.userid);
}).then(_mtime => {
mtime = _mtime;
return remoteHistory.list();
}).then(list => {
return this._next(list.data, mtime, options.userid, 0).then(() => {
if (list.data.length === 0) {
return Promise.resolve();
}
var latestMtime = list.data[0].last_modified;
return HistoryHelper.setSyncedCollectionMtime(latestMtime,
options.userid);
}).then(() => {
// Always return false for a read-only operation.
return Promise.resolve(false);
});
}).catch(err => {
console.error('History DataAdapter update error', err.message);
throw err;
});
},
handleConflict(conflict) {
// Because History adapter has not implemented record push yet,
// handleConflict will always use remote records.
return Promise.resolve(conflict.remote);
},
reset(options) {
return LazyLoader.load(['shared/js/async_storage.js']).then(() => {
return HistoryHelper.reset(options.userid);
});
}
};

View file

@ -0,0 +1,18 @@
{
"name": "sync",
"tags": {
"dictionaries": ["jsdoc"]
},
"source": {
"include": ["!apps/sync/js/ext/*.js", "apps/sync/js/**/*.js"]
},
"plugins": [
"plugins/markdown",
"plugins/summarize"
],
"opts": {
"private": true,
"recurse": true,
"destination": "docs/sync"
}
}

View file

@ -0,0 +1 @@
{"name":"Sync","description":"Syncs data with Firefox Sync","type":"certified","role":"system","launch_path":"/index.html","developer":{"name":"The Gaia Team","url":"https://github.com/mozilla-b2g/gaia"},"permissions":{"settings":{"access":"readonly"}},"datastores-owned":{"sync_bookmarks_store":{"access":"readwrite","description":"View and modify the bookmarks from Firefox Sync."}},"datastores-access":{"places":{"readonly":false,"description":"Stores data about browsing history."},"bookmarks_store":{"readonly":false,"description":"Stores data about browsing history."}},"connections":{"gaia::sync::request":{"description":"Expose a simple API to be woken up on sync requests","rules":{"minimumAccessLevel":"certified"}}},"origin":"app://sync.gaiamobile.org","version":"2.5"}

View file

@ -0,0 +1,180 @@
/* globals indexedDB */
/**
* This file defines an asynchronous version of the localStorage API, backed by
* an IndexedDB database. It creates a global asyncStorage object that has
* methods like the localStorage object.
*
* To store a value use setItem:
*
* asyncStorage.setItem('key', 'value');
*
* If you want confirmation that the value has been stored, pass a callback
* function as the third argument:
*
* asyncStorage.setItem('key', 'newvalue', function() {
* console.log('new value stored');
* });
*
* To read a value, call getItem(), but note that you must supply a callback
* function that the value will be passed to asynchronously:
*
* asyncStorage.getItem('key', function(value) {
* console.log('The value of key is:', value);
* });
*
* Note that unlike localStorage, asyncStorage does not allow you to store and
* retrieve values by setting and querying properties directly. You cannot just
* write asyncStorage.key; you have to explicitly call setItem() or getItem().
*
* removeItem(), clear(), length(), and key() are like the same-named methods of
* localStorage, but, like getItem() and setItem() they take a callback
* argument.
*
* The asynchronous nature of getItem() makes it tricky to retrieve multiple
* values. But unlike localStorage, asyncStorage does not require the values you
* store to be strings. So if you need to save multiple values and want to
* retrieve them together, in a single asynchronous operation, just group the
* values into a single object. The properties of this object may not include
* DOM elements, but they may include things like Blobs and typed arrays.
*
* Unit tests are in apps/gallery/test/unit/asyncStorage_test.js
*/
this.asyncStorage = (function() {
'use strict';
var DBNAME = 'asyncStorage';
var DBVERSION = 1;
var STORENAME = 'keyvaluepairs';
var db = null;
function withDatabase(f) {
if (db) {
f();
} else {
var openreq = indexedDB.open(DBNAME, DBVERSION);
openreq.onerror = function withStoreOnError() {
console.error('asyncStorage: can\'t open database:',
openreq.error.name);
};
openreq.onupgradeneeded = function withStoreOnUpgradeNeeded() {
// First time setup: create an empty object store
openreq.result.createObjectStore(STORENAME);
};
openreq.onsuccess = function withStoreOnSuccess() {
db = openreq.result;
f();
};
}
}
function withStore(type, callback, oncomplete) {
withDatabase(function() {
var transaction = db.transaction(STORENAME, type);
if (oncomplete) {
transaction.oncomplete = oncomplete;
}
callback(transaction.objectStore(STORENAME));
});
}
function getItem(key, callback) {
var req;
withStore('readonly', function getItemBody(store) {
req = store.get(key);
req.onerror = function getItemOnError() {
console.error('Error in asyncStorage.getItem(): ', req.error.name);
};
}, function onComplete() {
var value = req.result;
if (value === undefined) {
value = null;
}
callback(value);
});
}
function setItem(key, value, callback) {
withStore('readwrite', function setItemBody(store) {
var req = store.put(value, key);
req.onerror = function setItemOnError() {
console.error('Error in asyncStorage.setItem(): ', req.error.name);
};
}, callback);
}
function removeItem(key, callback) {
withStore('readwrite', function removeItemBody(store) {
var req = store.delete(key);
req.onerror = function removeItemOnError() {
console.error('Error in asyncStorage.removeItem(): ', req.error.name);
};
}, callback);
}
function clear(callback) {
withStore('readwrite', function clearBody(store) {
var req = store.clear();
req.onerror = function clearOnError() {
console.error('Error in asyncStorage.clear(): ', req.error.name);
};
}, callback);
}
function length(callback) {
var req;
withStore('readonly', function lengthBody(store) {
req = store.count();
req.onerror = function lengthOnError() {
console.error('Error in asyncStorage.length(): ', req.error.name);
};
}, function onComplete() {
callback(req.result);
});
}
function key(n, callback) {
if (n < 0) {
callback(null);
return;
}
var req;
withStore('readonly', function keyBody(store) {
var advanced = false;
req = store.openCursor();
req.onsuccess = function keyOnSuccess() {
var cursor = req.result;
if (!cursor) {
// this means there weren't enough keys
return;
}
if (n === 0 || advanced) {
// Either 1) we have the first key, return it if that's what they
// wanted, or 2) we've got the nth key.
return;
}
// Otherwise, ask the cursor to skip ahead n records
advanced = true;
cursor.advance(n);
};
req.onerror = function keyOnError() {
console.error('Error in asyncStorage.key(): ', req.error.name);
};
}, function onComplete() {
var cursor = req.result;
callback(cursor ? cursor.key : null);
});
}
return {
getItem: getItem,
setItem: setItem,
removeItem: removeItem,
clear: clear,
length: length,
key: key
};
}());

View file

@ -0,0 +1,35 @@
/* globals dump */
(function() {
'use strict';
function dump_off(msg, optionalObject) {}
function dump_on(msg, optionalObject) {
var output = msg;
if (optionalObject) {
try {
output += JSON.stringify(optionalObject);
} catch(e) {}
}
if (dump) {
var appName = document.location.hostname.replace(/\..*$/, '');
dump('[' + appName + '] ' + output + '\n');
} else {
console.log(output);
}
}
window.DUMP = dump_off; // no traces by default
// enable/disable DUMP according to the related setting
var settings = window.navigator.mozSettings;
var reqGaiaDebug = settings.createLock().get('debug.gaia.enabled');
reqGaiaDebug.onsuccess = function gaiaDebug() {
window.DUMP =
reqGaiaDebug.result['debug.gaia.enabled'] ? dump_on : dump_off;
};
settings.addObserver('debug.gaia.enabled', function(event) {
window.DUMP = event.settingValue ? dump_on : dump_off;
dump_on(event.settingValue ? 'Enabling DUMP' : 'Disabling DUMP');
});
}());

View file

@ -0,0 +1,49 @@
'use strict';
var IACHandler = {
_eventPrefix: 'iac-',
_ports: {},
init: function onInit() {
var self = this;
window.navigator.mozSetMessageHandler('connection',
function onConnected(request) {
var keyword = request.keyword;
var port = request.port;
// We will save each port by keyword; ports may get overwritten
// sometimes, but that's ok because there should only be one *active*
// port with a given keyword at any time.
self._ports[keyword] = port;
port.onmessage = function onReceivedMessage(evt) {
var message = evt.data;
/*
* System will emit a event when received message
* e.g. iac-ftucomms
* iac-[keywords]
*/
var evtName = self._eventPrefix + keyword;
var iacEvt = document.createEvent('CustomEvent');
/*
* You can get the message by accessing
* `event.detail`
*
* It will store the whole message you passed from postMessage
*/
iacEvt.initCustomEvent(evtName,
/* canBubble: */ true, /* cancelable */ false, message);
window.dispatchEvent(iacEvt);
};
});
},
getPort: function(keyword) {
return this._ports[keyword];
}
};
IACHandler.init();

View file

@ -0,0 +1,154 @@
/* exported LazyLoader */
/* globals HtmlImports, Promise */
'use strict';
/**
* This contains a simple LazyLoader implementation
* To use:
*
* LazyLoader.load(
* ['/path/to/file.js', '/path/to/file.css', 'domNode'], callback
* );
*/
var LazyLoader = (function() {
function LazyLoader() {
this._loaded = {};
this._isLoading = {};
}
LazyLoader.prototype = {
_js: function(file, callback) {
var script = document.createElement('script');
script.src = file;
// until bug 916255 lands async is the default so
// we must disable it so scripts load in the order they where
// required.
script.async = false;
script.addEventListener('load', callback);
document.head.appendChild(script);
this._isLoading[file] = script;
},
_css: function(file, callback) {
var style = document.createElement('link');
style.type = 'text/css';
style.rel = 'stylesheet';
style.href = file;
document.head.appendChild(style);
callback();
},
_html: function(domNode, callback) {
// The next few lines are for loading html imports in DEBUG mode
if (domNode.getAttribute('is')) {
this.load(['/shared/js/html_imports.js'], function() {
HtmlImports.populate(callback);
}.bind(this));
return;
}
for (var i = 0; i < domNode.childNodes.length; i++) {
if (domNode.childNodes[i].nodeType == document.COMMENT_NODE) {
domNode.innerHTML = domNode.childNodes[i].nodeValue;
break;
}
}
window.dispatchEvent(new CustomEvent('lazyload', {
detail: domNode
}));
callback();
},
/**
* Retrieves content of JSON file.
*
* @param {String} file Path to JSON file
* @param {Boolean} mozSystem If xhr should use mozSystem permissions
* @return {Promise} A promise that resolves to the JSON content
* or null in case of invalid path. Rejects if an error occurs.
*/
getJSON: function(file, mozSystem) {
return new Promise(function(resolve, reject) {
var xhr;
if (mozSystem) {
xhr = new XMLHttpRequest({mozSystem: true});
} else {
xhr = new XMLHttpRequest();
}
xhr.open('GET', file, true);
xhr.responseType = 'json';
xhr.onerror = function(error) {
reject(error);
};
xhr.onload = function() {
if (xhr.response !== null) {
resolve(xhr.response);
} else {
reject(new Error('No valid JSON object was found (' +
xhr.status + ' ' + xhr.statusText + ')'));
}
};
xhr.send();
});
},
load: function(files, callback) {
var deferred = {};
deferred.promise = new Promise(resolve => {
deferred.resolve = resolve;
});
if (!Array.isArray(files)) {
files = [files];
}
var loadsRemaining = files.length, self = this;
function perFileCallback(file) {
if (self._isLoading[file]) {
delete self._isLoading[file];
}
self._loaded[file] = true;
if (--loadsRemaining === 0) {
deferred.resolve();
if (callback) {
callback();
}
}
}
for (var i = 0; i < files.length; i++) {
var file = files[i];
if (this._loaded[file.id || file]) {
perFileCallback(file);
} else if (this._isLoading[file]) {
this._isLoading[file].addEventListener(
'load', perFileCallback.bind(null, file));
} else {
var method, idx;
if (typeof file === 'string') {
method = file.match(/\.([^.]+)$/)[1];
idx = file;
} else {
method = 'html';
idx = file.id;
}
this['_' + method](file, perFileCallback.bind(null, idx));
}
}
return deferred.promise;
}
};
return new LazyLoader();
}());

View file

@ -0,0 +1,152 @@
/* 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';
/* global
DUMP,
ERROR_SYNC_APP_SYNC_IN_PROGRESS,
ERROR_SYNC_INVALID_REQUEST_OPTIONS,
IACHandler,
LazyLoader,
SyncEngine
*/
/* exported
Bootstrap,
DataAdapters
*/
var DataAdapters = {
// To be filled by js/adapters/*.js
};
if (!window.DUMP) {
window.DUMP = () => {};
}
const DEBUG = (msg, args) => {
DUMP(`[Sync] ${msg}`, args);
};
const Bootstrap = (() => {
var running = false;
const loadMainScripts = () => {
return LazyLoader.load([
'shared/js/sync/crypto/stringconversion.js',
'shared/js/sync/crypto/keyderivation.js',
'shared/js/sync/crypto/fxsyncwebcrypto.js',
'shared/js/sync/ext/kinto.min.js',
'shared/js/sync/engine.js'
]);
};
const loadDataAdapterScript = (collectionName) => {
return LazyLoader.load(`js/adapters/${collectionName}.js`);
};
const loadErrorConstants = () => {
return LazyLoader.load('shared/js/sync/errors.js');
};
/**
* @param request {Object} Should contain the following fields:
* * URL {String} Like 'http://localhost:8000/v1/'.
* * assertion {String} A BrowserID assertion.
* * keys {Object} Like `{ kA: 'foo', kB: 'bar' }`.
* * collections {Object} Like `{ history: {} }`.
*/
const handleSyncRequest = (request) => {
if ((typeof request !== 'object') ||
(typeof request.URL !== 'string') ||
(typeof request.assertion !== 'string') ||
(typeof request.keys !== 'object') ||
(typeof request.keys.kB !== 'string') ||
(typeof request.collections !== 'object')) {
return loadErrorConstants().then(() => {
throw new Error(ERROR_SYNC_INVALID_REQUEST_OPTIONS);
});
}
if (running) {
return loadErrorConstants().then(() => {
throw new Error(ERROR_SYNC_APP_SYNC_IN_PROGRESS);
});
}
running = true;
return loadMainScripts().then(() => {
const collectionNames = Object.keys(request.collections);
return Promise.all(collectionNames.map(collectionName => {
return loadDataAdapterScript(collectionName);
}));
}).then(() => {
var syncEngine = new SyncEngine({
kB: request.keys.kB,
URL: request.URL,
assertion: request.assertion,
adapters: DataAdapters
});
return syncEngine.syncNow(request.collections);
}).then(() => {
running = false;
}).catch(err => {
running = false;
throw err;
});
};
const sendPortMessage = message => {
var port = IACHandler.getPort('gaia::sync::request', this);
if (port) {
DEBUG('Port message', message);
port.postMessage(message);
} else {
console.error('No gaia::sync::request port');
}
};
window.addEventListener('iac-gaia::sync::request', event => {
if (!event || !event.detail || !event.detail.id) {
console.error('Wrong IAC request');
window.close();
return;
}
const request = event.detail;
DEBUG('Sync request', request);
switch (request.name) {
case 'sync':
handleSyncRequest(request).then(() => {
sendPortMessage({
id: request.id
});
window.close();
}).catch(error => {
DEBUG('Sync request error', error.message || error.name || error);
sendPortMessage({
id: request.id,
error: { message: error.message }
});
window.close();
});
break;
case 'cancel':
console.warn('Closing app');
window.close();
break;
default:
console.error('Unknown IAC request');
window.close();
}
});
// Expose Bootstrap for unit testing:
return { handleSyncRequest };
})();

View file

@ -0,0 +1,307 @@
/* 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';
/* global
crypto,
KeyDerivation,
StringConversion
*/
/* exported
FxSyncWebCrypto
*/
// WebCrypto-based client for Firefox Sync.
const FxSyncWebCrypto = Object.freeze((() => {
const HKDF_INFO_STR = 'identity.mozilla.com/picl/v1/oldsync';
const importKeyBundle = (aesKeyAB, hmacKeyAB) => {
const pAes = crypto.subtle.importKey('raw', aesKeyAB,
{ name: 'AES-CBC', length: 256 },
true, [ 'encrypt', 'decrypt' ]);
const pHmac = crypto.subtle.importKey('raw', hmacKeyAB,
{ name: 'HMAC', hash: 'SHA-256' },
true, [ 'sign', 'verify' ]);
return Promise.all([pAes, pHmac]).then(results => {
return Object.freeze({
aes: results[0],
hmac: results[1]
});
}, () => {
// err might be a TypeError if aesKeyAB or hmacKeyAB was no ArrayBuffer.
throw new Error('Could not import key bundle.');
});
};
const importKb = function(kBByteArray) {
// The number 64 here comes from
// (256 bits for AES + 256 bits for HMAC) / (8 bits per byte).
return KeyDerivation.hkdf(kBByteArray,
StringConversion.stringToUtf8Uint8Array(
HKDF_INFO_STR),
new Uint8Array(64), 64).then(output => {
const aesKeyAB = output.slice(0, 32).buffer;
const hmacKeyAB = output.slice(32).buffer;
return Object.freeze(importKeyBundle(aesKeyAB, hmacKeyAB));
});
};
const verifySyncKeys = function(
signedTextByteArray,
cryptoKeysHmacByteArray,
hmacKey) {
return crypto.subtle.verify({ name: 'HMAC', hash: 'AES-256' },
hmacKey, cryptoKeysHmacByteArray, signedTextByteArray);
};
const importSyncKeys = function(
cryptoKeysIVByteArray,
cryptoKeysCiphertextByteArray,
aesKey) {
return crypto.subtle.decrypt({ name: 'AES-CBC', iv: cryptoKeysIVByteArray },
aesKey,
cryptoKeysCiphertextByteArray)
.then(keyBundleAB => {
const cryptoKeysJSON = String.fromCharCode.apply(
null,
new Uint8Array(keyBundleAB));
var bulkKeyBundle;
try {
bulkKeyBundle = JSON.parse(cryptoKeysJSON);
return importKeyBundle(
StringConversion.base64StringToUint8Array(
bulkKeyBundle.default[0]),
StringConversion.base64StringToUint8Array(
bulkKeyBundle.default[1])).then(keyBundle => {
bulkKeyBundle.defaultAsKeyBundle = keyBundle;
return Object.freeze(bulkKeyBundle);
});
} catch(e) {
throw new Error('Deciphered crypto keys, but not JSON');
}
}, () => {
throw new Error(`Could not decrypt crypto keys using AES part of stretche\
d kB key`);
});
};
const importFromStrings = (obj) => {
const ret = {};
try {
ret.ciphertext = StringConversion.
base64StringToUint8Array(obj.ciphertext);
} catch (e) {
throw new Error('Could not parse ciphertext as a base64 string');
}
// Intentionally using StringConversion.stringToUtf8Uint8Array
// instead of StringConversion.base64StringToUint8Array on the ciphertext
// here - see https://github.com/mozilla/firefox-ios/blob/ \
// 1cce59c8eac282e151568f1204ffbbcc27349eff/Sync/KeyBundle.swift#L178.
ret.hmacSignedText = StringConversion
.stringToUtf8Uint8Array(obj.ciphertext);
try {
ret.IV = StringConversion.base64StringToUint8Array(obj.IV);
} catch (e) {
throw new Error('Could not parse IV as a base64 string');
}
try {
ret.hmacSignature = StringConversion.hexStringToUint8Array(obj.hmac);
} catch (e) {
throw new Error('Could not parse hmac as a hex string');
}
return Object.freeze(ret);
};
const getBulkKeyBundle = (kBByteArray, cryptoKeys) => {
return importKb(kBByteArray).then(mainSyncKey => {
return verifySyncKeys(cryptoKeys.hmacSignedText,
cryptoKeys.hmacSignature,
mainSyncKey.hmac).then(verified => {
if (verified) {
return importSyncKeys(cryptoKeys.IV,
cryptoKeys.ciphertext,
mainSyncKey.aes);
}
throw new Error(new Error(`SyncKeys hmac could not be verified with cur\
rent main key`));
});
});
};
/*
* setKeys - import kB and crypto/keys
*
* @param {String} kB Hex string with kB from FxA onepw protocol
* @param {Object} cryptoKeysStrings Object with:
* - ciphertext {String} A Base64 String containing an AES-CBC ciphertext
* - IV {String} A Base64 String containing the AES-CBC Initialization
* Vector
* - hmac {String} A Hex String containing the HMAC-SHA256 signature
* @returns {Promise} A promise that will resolve after importing kB,
* decrypting cryptoKeys, and setting this.bulkKeyBundle.
*/
const setKeys = function(kB, cryptoKeysStrings) {
var kBByteArray, cryptoKeys;
// Input checking.
try {
kBByteArray = StringConversion.hexStringToUint8Array(kB);
} catch (e) {
return Promise.reject(new Error('Could not parse kB as a hex string'));
}
try {
cryptoKeys = importFromStrings(cryptoKeysStrings);
} catch(err) {
return Promise.reject(err);
}
return getBulkKeyBundle(kBByteArray, cryptoKeys).then(bulkKeyBundle => {
this.bulkKeyBundle = bulkKeyBundle;
});
};
/*
* decrypt - verify and decrypt a Weave Basic Object
*
* @param {Object} payloadStrings Object with:
* - ciphertext {String} A Base64 String containing an AES-CBC ciphertext
* - IV {String} A Base64 String containing the AES-CBC Initialization
* Vector
* - hmac {String} A Hex String containing the HMAC-SHA256 signature
* @param {String} collectionName String The name of the Sync collection
* (currently ignored, see
* https://github.com/michielbdejong/fxsync-webcrypto/issues/19)
* @returns {Promise} A promise for the decrypted Weave Basic Object.
*/
const decrypt = function(payloadStrings, collectionName) {
if (typeof payloadStrings !== 'object') {
return Promise.reject(new Error('PayloadStrings is not an object'));
}
if (typeof collectionName !== 'string') {
return Promise.reject(new Error('collectionName is not a string'));
}
var keyBundle;
try {
keyBundle = this.bulkKeyBundle.defaultAsKeyBundle;
} catch(e) {
return Promise.reject(new Error(`No key bundle found for \
${collectionName} - did you call setKeys?`));
}
const payload = importFromStrings(payloadStrings);
return crypto.subtle.verify({ name: 'HMAC', hash: 'SHA-256' },
keyBundle.hmac, payload.hmacSignature,
payload.hmacSignedText).then(result => {
if (!result) {
throw new Error(`Record verification failed with current hmac key for \
${collectionName}`);
}
}).then(() => {
return crypto.subtle.decrypt({ name: 'AES-CBC', iv: payload.IV },
keyBundle.aes,
payload.ciphertext)
.then(recordArrayBuffer => {
var recordObj;
// recordArrayBuffer which is the cleartext payload from FxSync contains
// UTF-8 JSON.
const recordJSON = StringConversion.utf8Uint8ArrayToString(
new Uint8Array(recordArrayBuffer));
try {
recordObj = JSON.parse(recordJSON);
} catch(e) {
throw new Error('Deciphered record, but not JSON');
}
return recordObj;
}, () => {
throw new Error(`Could not decrypt record using AES part of key bundle \
for collection ${collectionName}`);
});
});
};
const encryptAndSign = (keyBundle, cleartext) => {
// Generate a random IV using the PRNG of the device.
var IV = new Uint8Array(16);
crypto.getRandomValues(IV);
return crypto.subtle.encrypt({ name: 'AES-CBC', iv: IV }, keyBundle.aes,
cleartext).then(ciphertext => {
const ciphertextB64 = StringConversion.arrayBufferToBase64String(
ciphertext);
return crypto.subtle.sign({ name: 'HMAC', hash: 'SHA-256' },
keyBundle.hmac,
StringConversion.stringToUtf8Uint8Array(
ciphertextB64)).
then(hmac => {
return {
hmac: StringConversion.arrayBufferToHexString(hmac),
ciphertext: ciphertextB64,
IV: StringConversion.uint8ArrayToBase64String(IV)
};
});
});
};
/*
* encrypt - encrypt and sign a record
*
* @param {Object} record Object The data to be JSON-stringified and stored
* @param {String} collectionName String The name of the Sync collection
* (currently ignored, see
* https://github.com/michielbdejong/fxsync-webcrypto/issues/19)
* @returns {Promise} A promise for an object with ciphertext, IV, and hmac.
*/
const encrypt = function(record, collectionName) {
if (typeof record !== 'object') {
return Promise.reject(new Error('Record should be an object'));
}
if (typeof collectionName !== 'string') {
return Promise.reject(new Error('collectionName is not a string'));
}
var cleartextStr, keyBundle;
try {
cleartextStr = JSON.stringify(record);
} catch(e) {
return Promise.reject(new Error('Record cannot be JSON-stringified'));
}
const cleartext = StringConversion.stringToUtf8Uint8Array(cleartextStr);
try {
keyBundle = this.bulkKeyBundle.defaultAsKeyBundle;
} catch(e) {
return Promise.reject(new Error(`No key bundle found for \
${collectionName} - did you call setKeys?`));
}
return encryptAndSign(keyBundle, cleartext);
};
const FxSyncWebCrypto = function() {
// Basic check for presence of WebCrypto.
if (!crypto || !crypto.subtle) {
throw new Error('This environment does not support WebCrypto');
}
this.bulkKeyBundle = null;
};
FxSyncWebCrypto.prototype = Object.freeze({
setKeys: setKeys,
decrypt: decrypt,
encrypt: encrypt
});
// Expose this for unit test:
FxSyncWebCrypto.importKeyBundle = Object.freeze(importKeyBundle);
return FxSyncWebCrypto;
})());

View file

@ -0,0 +1,97 @@
/* 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';
/* global
StringConversion
*/
/* exported
KeyDerivation
*/
const KeyDerivation = Object.freeze((() => {
// Hash length is 32 because only SHA256 is used at this moment.
const HASH_LENGTH = 32;
const subtle = window.crypto.subtle;
const concatU8Array = (buffer1, buffer2) => {
var aux = new Uint8Array(buffer1.byteLength + buffer2.byteLength);
aux.set(new Uint8Array(buffer1), 0);
aux.set(new Uint8Array(buffer2), buffer1.byteLength);
return aux;
};
const alg = {
name: 'HMAC',
hash: 'SHA-256'
};
const doImportKey = (rawKey) => {
return subtle.importKey('raw', rawKey, alg, false, ['sign']);
};
// Converts a ArrayBuffer into a ArrayBufferView (U8) if it's not that
// already.
const arrayBuffer2Uint8 = (buff) => {
return buff.buffer && buff || new Uint8Array(buff);
};
const doHMAC = (tbsData, hmacKey) => {
return subtle.sign(alg.name, hmacKey, tbsData).then(arrayBuffer2Uint8);
};
const bitSlice = (arr, start, end) => {
return (end !== undefined ? arr.subarray(start / 8, end / 8) :
arr.subarray(start / 8));
};
const newEmptyArray = () => new Uint8Array(0);
return {
/**
* hkdf - The HMAC-based Key Derivation Function
*
* @param {bitArray} ikm Initial keying material
* @param {bitArray} info Key derivation data
* @param {bitArray} salt Salt
* @param {integer} length Length of the derived key in bytes
* @return promise object- It will resolve with `output` data
*/
hkdf: (ikm, info, salt, length) => {
const numBlocks = Math.ceil(length / HASH_LENGTH);
const doHKDFRound = (roundNumber, prevDigest, prevOutput, hkdfKey) => {
// Do the data accumulating part of an HKDF round. Also, it
// checks if there are still more rounds left and fires the next
// Or just finishes the process calling the callback.
const addToOutput = (digest) => {
const output = prevOutput +
StringConversion.uint8ArrayToHexString(digest);
if (++roundNumber <= numBlocks) {
return doHKDFRound(roundNumber, digest, output, hkdfKey);
}
return new Promise(resolve => {
const truncated = bitSlice(
StringConversion.hexStringToUint8Array(output), 0, length * 8);
resolve(truncated);
});
};
const input = concatU8Array(
concatU8Array(prevDigest, info),
StringConversion.stringToUtf8Uint8Array(
String.fromCharCode(roundNumber)));
return doHMAC(input, hkdfKey).then(addToOutput);
};
return doImportKey(salt). // Imports the initial key.
then(doHMAC.bind(undefined, ikm)). // Generates the key deriving key.
then(doImportKey). // Imports the key deriving key.
then(doHKDFRound.bind(undefined, 1, newEmptyArray(), ''));
// Launches the first HKDF round.
}
};
})());

View file

@ -0,0 +1,108 @@
/* 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';
/* exported
StringConversion
*/
const StringConversion = Object.freeze((() => {
return {
rawStringToUint8Array(str) {
if (typeof str != 'string') {
throw new Error('Not a string');
}
const strLen = str.length;
var byteArray = new Uint8Array(strLen);
for (var i = 0; i < strLen; i++) {
byteArray[i] = str.charCodeAt(i);
}
return byteArray;
},
stringToUtf8Uint8Array(str) {
if (typeof str != 'string') {
throw new Error('Not a string');
}
var string = unescape(encodeURIComponent(str)),
charList = string.split(''),
uintArray = [];
for (var i = 0; i < charList.length; i++) {
uintArray.push(charList[i].charCodeAt(0));
}
return new Uint8Array(uintArray);
},
base64StringToUint8Array(base64) {
if (typeof base64 != 'string' || base64.length % 4 !== 0) {
throw new Error(`Number of base64 digits must be a multiple of 4 to con\
vert to bytes`);
}
return StringConversion.rawStringToUint8Array(window.atob(base64));
},
hexStringToUint8Array(hexStr) {
if (typeof hexStr != 'string' || hexStr.length % 2 !== 0) {
throw new Error(`Must have an even number of hex digits to convert to b\
ytes`);
}
const numBytes = hexStr.length / 2;
var byteArray = new Uint8Array(numBytes);
for (var i = 0; i < numBytes; i++) {
byteArray[i] = parseInt(hexStr.substr(i * 2, 2), 16);
}
return byteArray;
},
uint8ArrayToBase64String(bytes) {
if (!(bytes instanceof Uint8Array)) {
throw new Error('Not a Uint8Array');
}
var binary = '';
const len = bytes.byteLength;
for (var i=0; i<len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
},
arrayBufferToBase64String(buffer) {
if (!(buffer instanceof ArrayBuffer)) {
throw new Error('Not an ArrayBuffer');
}
var bytes = new Uint8Array(buffer);
return StringConversion.uint8ArrayToBase64String(bytes);
},
utf8Uint8ArrayToString(array) {
if (!(array instanceof Uint8Array)) {
throw new Error('Not an Uint8Array');
}
var utf8_string = String.fromCharCode.apply(null, array);
return decodeURIComponent(escape(utf8_string));
},
uint8ArrayToHexString(bytes) {
if (!(bytes instanceof Uint8Array)) {
throw new Error('Not a Uint8Array');
}
var hex = '';
for (var i=0; i <bytes.length; ++i) {
const zeropad = (bytes[i] < 0x10) ? '0' : '';
hex += zeropad + bytes[i].toString(16);
}
return hex;
},
arrayBufferToHexString(buffer) {
if (!(buffer instanceof ArrayBuffer)) {
throw new Error('Not an ArrayBuffer');
}
var bytes = new Uint8Array(buffer);
return StringConversion.uint8ArrayToHexString(bytes);
}
};
})());

View file

@ -0,0 +1,465 @@
/* 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/. */
/**
* SyncEngine
*
* The SyncEngine constructor takes care of:
* - Setting up the kinto.js client.
* - Configuring kinto.js to connect to the remote Syncto server (which in turn
* proxies all requests to the remote Firefox Sync server).
* - Creating kinto.js collections for the 'meta' and 'crypto' collections, with
* the appropriate IdSchemas (the 'meta' collection only contains one record,
* called 'global', and the 'crypto' collection only contains one record,
* called 'keys').
* - Setting up the FxSyncWebCrypto instance to be used for cryptographically
* encoding / decoding records, before uploading / after downloading them.
* - Registering the DataAdapters to be used on each collection (see below).
*
* The `syncNow` method takes care of:
* - If called for the first time, or if this failed the previous time:
* - Retrieving 'meta/global' to check the storageVersion.
* - Retrieving and decrypting 'crypto/keys' to obtain the bulk key bundle.
* - Making sure a kinto.js collection exists for each collection that needs
* syncing.
* - These collections are set up with the correct IdSchema (typically
* allowing only 9-character URL-safe Base64 strings, although for some
* collections other strings are allowed, e.g. bookmarks also has id's like
* 'menu' and 'toolbar').
* - Adding a RemoteTransformer to each of these collections, so that outgoing
* and incoming data is passed through FxSyncWebCrypto for its en-/decryption.
* - For each collection to be synced, the following steps occur:
* - Download the latest remote data into the local decrypted copy, managed by
* kinto.js.
* - For each incoming conflict reported by kinto.js (if any), call the
* `handleConflict` method on the DataAdapter registered for this
* collection.
* - When kinto.js has obtained a full decrypted copy of the remote data, call
* the `update` method on the DataAdapter, passing it an interface to the
* Kinto collection in question.
* - When this method returns its promise, sync the Kinto collection a second
* time, to make sure any changes made there by the DataAdapter are synced
* up to the remote Firefox Sync server (again, through the remote Syncto
* proxy server). The RemoteTransformer on the Kinto collection will take
* care of encrypting the records before they get uploaded.
* - Call the `handleConflict` method on the DataAdapter again for any
* conflicts kinto.js may report during this second sync process.
*
*/
'use strict';
/* global
crypto,
FxSyncWebCrypto,
Kinto
*/
/* exported
SyncEngine
*/
var SyncEngine = (function() {
const HTTP_TIMEOUT_SECONDS = 180;
var createFxSyncIdSchema = () => {
return {
generate: function() {
var bytes = new Uint8Array(9);
crypto.getRandomValues(bytes);
var binStr = '';
for (var i = 0; i < 9; i++) {
binStr += String.fromCharCode(bytes[i]);
}
// See https://docs.services.mozilla.com/storage/apis-1.5.html
return window.btoa(binStr).replace('+', '-').replace('/', '_');
},
validate: function(id) {
// FxSync id's "should" be 12 ASCII characters, representing 9 bytes of
// data in modified Base64 for URL variants, where the '+' and '/'
// characters of standard Base64 are respectively replaced by '-' and
// '_'. See https://docs.services.mozilla.com/storage/apis-1.5.html
// But in practice, they could be any string, see bug 1209906.
return (typeof id === 'string');
}
};
};
var createControlCollectionIdSchema = (keyName) => {
return {
generate: function() {
return keyName;
},
validate: function(id) {
return (id === keyName);
}
};
};
var createWebCryptoTransformer = (collectionName, fswc) => {
if (!fswc.bulkKeyBundle) {
throw new Error(
'Attempt to register Transformer with no bulk key bundle!');
}
return {
encode: function(record) {
return fswc.encrypt(record.payload, collectionName).then(
payloadEnc => {
return {
id: record.id,
payload: JSON.stringify(payloadEnc)
};
});
},
decode: function(record) {
// Allowing JSON.parse errors to bubble up to the errors list in the
// syncResults:
return fswc.decrypt(JSON.parse(record.payload), collectionName).
then(payloadDec => {
return {
id: record.id,
last_modified: record.last_modified,
payload: payloadDec
};
});
}
};
};
const generateXClientState = (kB) => {
var kBarray = [];
for(var i = 0; i < kB.length; i += 2){
kBarray.push(parseInt(kB.substring(i, i + 2), 16));
}
return window.crypto.subtle.digest({name: 'SHA-256'},
new Uint8Array(kBarray))
.then(hash => {
var bytes = new Uint8Array(hash).slice(0, 16);
var array = [];
bytes.forEach(byte => {
array.push((byte < 16 ? '0' : '') + byte.toString(16).toLowerCase());
});
return array.join('');
});
};
/**
* SyncEngine - Constructor.
* @param {Object} options Should contain the following fields:
* * URL - e.g. 'http://localhost:8000/v1/'
* * assertion - a BrowserID assertion (Base64)
* * kB - kB key from token server (Base64)
* * adapters - object whose keys are collection
* names, and whose values are
* DataAdapter instances.
* @returns {SyncEngine}
*/
var SyncEngine = function(options) {
if (typeof options !== 'object') {
throw new Error('options should be an Object');
}
['URL', 'assertion', 'kB'].forEach(field => {
if (typeof options[field] !== 'string') {
throw new Error(`options.${field} should be a String`);
}
});
if (typeof options.adapters !== 'object') {
throw new Error('options.adapters should be an Object');
}
for (var collectionName in options.adapters) {
if (typeof options.adapters[collectionName] !== 'object') {
throw new Error(`options.adapters.${collectionName} should be an Object\
`);
}
['update', 'handleConflict'].forEach(methodName => {
if (typeof options.adapters[collectionName][methodName] !==
'function') {
throw new Error(`options.adapters.${collectionName}.${methodName} sho\
uld be a Function`);
}
});
}
['kB', 'assertion', 'URL', 'adapters'].forEach(field => {
this[`_${field}`] = options[field];
});
this._collections = {};
this._controlCollections = {};
this._fswc = new FxSyncWebCrypto();
this._engines = null;
this._kinto = null;
this._xClientState = null;
this._haveUnsyncedConflicts = {};
this._ready = false;
};
function promiseWaterfall(self, tasks, executor) {
return tasks.reduce((reduced, current) => {
return reduced.then(() => {
return executor.apply(self, current);
});
}, Promise.resolve());
}
SyncEngine.prototype = {
_reset: function(collectionName, userid) {
var collectionsToReset = ['meta', 'crypto'];
if (collectionName) {
collectionsToReset.push(collectionName);
}
return Promise.all(collectionsToReset.map(collectionName => {
var coll = this._getCollection(collectionName);
return coll.clear().then(() => {
if (this._adapters[collectionName]) {
return this._adapters[collectionName].reset({ userid });
}
});
}));
},
_createKinto: function(kintoCredentials) {
var kinto = new Kinto({
bucket: kintoCredentials.xClientState,
remote: kintoCredentials.URL,
timeout: HTTP_TIMEOUT_SECONDS * 1000,
headers: {
'Authorization': 'BrowserID ' + kintoCredentials.assertion
}
});
var addControlCollection = (collectionName, keyName) => {
var idSchema = createControlCollectionIdSchema(keyName);
this._controlCollections[collectionName] =
kinto.collection(collectionName, { idSchema });
};
addControlCollection('meta', 'global');
addControlCollection('crypto', 'keys');
return kinto;
},
_getCollection: function(collectionName) {
if (['meta', 'crypto'].indexOf(collectionName) === -1) {
return this._collections[collectionName];
}
return this._controlCollections[collectionName];
},
_getItem: function(collectionName, itemName, syncIfNeeded) {
return this._getCollection(collectionName).get(itemName).catch(err => {
if (syncIfNeeded) {
return this._syncCollection(collectionName).then(() => {
return this._getItem(collectionName, itemName, false);
});
}
if (err.message === 'Record with id=global not found.') {
throw new SyncEngine.InvalidAccountError();
}
throw err;
});
},
_resolveConflicts: function(collectionName, conflicts) {
return Promise.all(conflicts.map(conflict => {
return this._adapters[collectionName].handleConflict(conflict)
.then(resolution =>
this._collections[collectionName].resolve(conflict, resolution))
.then(() => this._haveUnsyncedConflicts[collectionName] = true);
}));
},
_syncCollection: function(collectionName, collectionOptions) {
var collection = this._getCollection(collectionName);
// Let synchronization strategy default to 'manual', see
// http://kintojs.readthedocs.org \
// /en/latest/api/#fetching-and-publishing-changes
var userid = collectionOptions && collectionOptions.userid;
return collection.sync({
fetchLimit: (collectionOptions && collectionOptions.limit) || undefined
}).then(syncResults => {
if (syncResults && syncResults.errors && syncResults.errors.length) {
var error = new Error('Errors in SyncResults');
error.data = syncResults;
throw error;
}
if (syncResults.conflicts.length) {
return this._resolveConflicts(collectionName, syncResults.conflicts);
}
}).catch(err => {
if (err instanceof Error && typeof err.response === 'object') {
if (err.response.status === 401) {
throw new SyncEngine.AuthError(err.message);
}
throw new SyncEngine.TryLaterError(err.message);
}
if (err.message === `HTTP 0; TypeError: NetworkError when attempting to\
fetch resource.`) {
throw new SyncEngine.TryLaterError('Syncto server unreachable',
this._kinto && this._kinto._options &&
this._kinto._options.remote);
}
return this._reset(collectionName, userid).then(
() => {
throw new SyncEngine.UnrecoverableError(err.message);
});
});
},
_checkMetaGlobal: function(metaGlobal) {
var payloadObj;
try {
payloadObj = JSON.parse(metaGlobal.data.payload);
} catch(e) {
return false;
}
this._engines = payloadObj.engines;
return (typeof payloadObj === 'object' &&
payloadObj.storageVersion === 5);
},
_initFxSyncWebCrypto: function(cryptoKeys) {
this._fswc = new FxSyncWebCrypto();
return this._fswc.setKeys(this._kB, cryptoKeys).then(() => {
this._createCollections();
this._ready = true;
}, err => {
if (err === 'SyncKeys hmac could not be verified with current main ' +
'key') {
this._reset().then(() => {
throw new SyncEngine.UnrecoverableError(err);
});
}
throw err;
});
},
_ensureReady: function() {
if (this._ready) {
return Promise.resolve();
}
return generateXClientState(this._kB).then(xClientState => {
this._kinto = this._createKinto({
URL: this._URL,
assertion: this._assertion,
xClientState
});
this._xClientState = xClientState;
}).then(() => {
return this._syncCollection('meta');
}).then(() => {
return this._getItem('meta', 'global');
}).then(metaGlobal => {
if (!this._checkMetaGlobal(metaGlobal)) {
throw new SyncEngine.UnrecoverableError(`Incompatible storage version\
or storage version not recognized.`);
}
});
},
_getBulkKeyBundle: function() {
return this._getItem('crypto', 'keys', true /* syncIfNeeded */)
.then((cryptoKeysRecord) => {
var cryptoKeys;
try {
cryptoKeys = JSON.parse(cryptoKeysRecord.data.payload);
} catch (e) {
throw new SyncEngine.UnrecoverableError(`Could not parse crypto/keys \
payload as JSON`);
}
return cryptoKeys;
}).then((cryptoKeys) => {
return this._initFxSyncWebCrypto(cryptoKeys);
});
},
_createCollections: function() {
for (var collectionName in this._adapters) {
this._collections[collectionName] = this._kinto.collection(
collectionName, {
idSchema: createFxSyncIdSchema(collectionName),
remoteTransformers: [
createWebCryptoTransformer(collectionName, this._fswc)
]
});
}
},
_updateCollection: function(collectionName, collectionOptions) {
if (this._engines && !this._engines[collectionName]) {
console.warn(`Collection ${collectionName} not present on this FxSync a\
ccount`);
return Promise.resolve();
}
return this._syncCollection(collectionName, collectionOptions)
.then(() => {
return this._adapters[collectionName].update(
this._collections[collectionName], collectionOptions);
}).then(changed => {
if (!changed && !this._haveUnsyncedConflicts[collectionName]) {
return Promise.resolve();
}
return this._syncCollection(collectionName, collectionOptions)
.then(() => {
this._haveUnsyncedConflicts[collectionName] = false;
});
});
},
/**
* syncNow - Syncs collections up and down between device and server.
* @param {object} collectionOptions The options per collection. Can be:
* {boolean} readonly (defaults to true)
* {number} limit
* @returns {Promise}
*/
syncNow: function(collectionOptions) {
if (typeof collectionOptions !== 'object') {
return Promise.reject(new Error(
'collectionOptions should be an object'));
}
return this._ensureReady().then(() => {
if (Object.keys(collectionOptions).length === 0) {
// This can happen if the system app just wanted to check if an
// FxSync account exists for the credentials derived from kB.
return Promise.resolve();
}
return this._getBulkKeyBundle().then(() => {
var tasks = [];
for (var collectionName in collectionOptions) {
collectionOptions[collectionName].userid = this._xClientState;
tasks.push([ collectionName, collectionOptions[collectionName] ]);
}
return promiseWaterfall(this, tasks, this._updateCollection);
});
});
}
};
SyncEngine.UnrecoverableError = function() {
console.error('[SyncEngine Unrecoverable]', arguments);
this.message = 'unrecoverable';
};
SyncEngine.UnrecoverableError.prototype = Object.create(Error.prototype);
SyncEngine.TryLaterError = function() {
console.error('[SyncEngine TryLater]', arguments);
this.message = 'try later';
};
SyncEngine.TryLaterError.prototype = Object.create(Error.prototype);
SyncEngine.AuthError = function() {
console.error('[SyncEngine Auth]', arguments);
this.message = 'unauthorized';
};
SyncEngine.AuthError.prototype = Object.create(Error.prototype);
SyncEngine.InvalidAccountError = function() {
console.error('[SyncEngine InvalidAccount]', arguments);
this.message = 'invalid account';
};
SyncEngine.InvalidAccountError.prototype = Object.create(Error.prototype);
return SyncEngine;
})();

View file

@ -0,0 +1,86 @@
/* 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';
/* global ERROR_DIALOG_CLOSED_BY_USER */
/* global ERROR_INVALID_SYNC_ACCOUNT */
/* global ERROR_NO_KEY_FETCH_TOKEN */
/* global ERROR_OFFLINE */
/* global ERROR_SYNC_APP_TRY_LATER */
/* global ERROR_UNKNOWN */
/**
* These errors are shared by all Firefox Sync related modules. The key should
* be used as a constant and the value can be used as a l10n string key if
* needed.
*/
(function(exports) {
// Unrecoverable errors will cause the Sync state machine to transition to
// the 'disabled' state.
const unrecoverableErrors = {
// The user closed the FxA dialog during the sync enabling state.
ERROR_DIALOG_CLOSED_BY_USER: 'fxsync-error-dialog-closed-by-user',
// Cannot get Firefox Accounts assertion.
ERROR_GET_FXA_ASSERTION: 'fxsync-error-get-fxa-assertion',
// The user logged in with an invalid account. This is probably because the
// account has no crypto keys associated and we are unable to generate them
// so far.
ERROR_INVALID_SYNC_ACCOUNT: 'fxsync-error-invalid-account',
// The device is offline.
ERROR_OFFLINE: 'fxsync-error-offline',
// The current login is invalid as it does not contain the token to fetch
// sync keys.
ERROR_NO_KEY_FETCH_TOKEN: 'fxsync-error-no-key-fetch-token',
// Cannot register sync request.
ERROR_REQUEST_SYNC_REGISTRATION: 'fxsync-error-request-fxsync-registration',
// The request options passed to the Sync app failed basic DataType checks.
ERROR_SYNC_INVALID_REQUEST_OPTIONS: 'fxsync-error-invalid-request-options',
// Cannot perform sync request.
ERROR_SYNC_REQUEST: 'fxsync-error-request-failed',
ERROR_UNKNOWN: 'fxsync-error-unknown'
};
const recoverableErrors = {
// The app was killed while performing a sync operation.
ERROR_SYNC_APP_KILLED: 'fxsync-error-app-killed',
// The app is already performing a sync request.
ERROR_SYNC_APP_SYNC_IN_PROGRESS: 'fxsync-error-app-fxsync-in-progress',
// Error while trying to sync.
ERROR_SYNC_APP_GENERIC: 'fxsync-error-app-generic',
// The server cannot be reached.
ERROR_SYNC_APP_TRY_LATER: 'fxsync-error-app-try-later',
// A DataStore was updated by another app at the same time.
ERROR_SYNC_APP_RACE_CONDITION: 'fxsync-error-app-race-condition',
// The user is logged in with an unverified account.
ERROR_UNVERIFIED_ACCOUNT: 'fxsync-error-unverified-account'
};
exports.SyncUnrecoverableErrors = [];
Object.keys(unrecoverableErrors).forEach(key => {
exports[key] = unrecoverableErrors[key];
exports.SyncUnrecoverableErrors.push(unrecoverableErrors[key]);
});
exports.SyncRecoverableErrors = [];
Object.keys(recoverableErrors).forEach(key => {
exports[key] = recoverableErrors[key];
exports.SyncRecoverableErrors.push(recoverableErrors[key]);
});
// Map between external (FxA, Syncto, kinto.js) and Sync errors.
exports.SyncErrors = {
'invalid account': ERROR_INVALID_SYNC_ACCOUNT,
'No keyFetchToken': ERROR_NO_KEY_FETCH_TOKEN,
'OFFLINE': ERROR_OFFLINE,
'try later': ERROR_SYNC_APP_TRY_LATER,
'UI_ERROR': ERROR_DIALOG_CLOSED_BY_USER,
'unauthorized': ERROR_INVALID_SYNC_ACCOUNT,
'UNVERIFIED_ACCOUNT': ERROR_INVALID_SYNC_ACCOUNT,
'unrecoverable': ERROR_UNKNOWN
};
}(window));

File diff suppressed because one or more lines are too long