zotero/chrome/content/zotero/xpcom/sync/syncAPIClient.js
Dan Stillman 18349b2232 Restore certificate checking for API syncing errors
Closes #864

This adds a 'channel' property to Zotero.HTTP.UnexpectedStatusException,
because the 'channel' property of the XHR can be garbage-collected
before handling, and the channel's 'securityInfo' property is necessary
to detect certificate errors.
2015-12-09 04:11:27 -05:00

645 lines
18 KiB
JavaScript

/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2014 Center for History and New Media
George Mason University, Fairfax, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
if (!Zotero.Sync) {
Zotero.Sync = {};
}
Zotero.Sync.APIClient = function (options) {
if (!options.baseURL) throw new Error("baseURL not set");
if (!options.apiVersion) throw new Error("apiVersion not set");
if (!options.apiKey) throw new Error("apiKey not set");
if (!options.caller) throw new Error("caller not set");
this.baseURL = options.baseURL;
this.apiVersion = options.apiVersion;
this.apiKey = options.apiKey;
this.caller = options.caller;
this.failureDelayIntervals = [2500, 5000, 10000, 20000, 40000, 60000, 120000, 240000, 300000];
}
Zotero.Sync.APIClient.prototype = {
MAX_OBJECTS_PER_REQUEST: 100,
getKeyInfo: Zotero.Promise.coroutine(function* () {
var uri = this.baseURL + "keys/" + this.apiKey;
var xmlhttp = yield this.makeRequest("GET", uri, { successCodes: [200, 404] });
if (xmlhttp.status == 404) {
return false;
}
var json = this._parseJSON(xmlhttp.responseText);
delete json.key;
return json;
}),
/**
* Get group metadata versions
*
* Note: This is the version for group metadata, not library data.
*/
getGroupVersions: Zotero.Promise.coroutine(function* (userID) {
if (!userID) throw new Error("User ID not provided");
var uri = this.baseURL + "users/" + userID + "/groups?format=versions";
var xmlhttp = yield this.makeRequest("GET", uri);
return this._parseJSON(xmlhttp.responseText);
}),
/**
* @param {Integer} groupID
* @return {Object|false} - Group metadata response, or false if group not found
*/
getGroupInfo: Zotero.Promise.coroutine(function* (groupID) {
if (!groupID) throw new Error("Group ID not provided");
var uri = this.baseURL + "groups/" + groupID;
var xmlhttp = yield this.makeRequest("GET", uri, { successCodes: [200, 404] });
if (xmlhttp.status == 404) {
return false;
}
return this._parseJSON(xmlhttp.responseText);
}),
getSettings: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, since) {
var params = {
libraryType: libraryType,
libraryTypeID: libraryTypeID,
target: "settings"
};
if (since) {
params.since = since;
}
var uri = this.buildRequestURI(params);
var options = {
successCodes: [200, 304]
};
if (since) {
options.headers = {
"If-Modified-Since-Version": since
};
}
var xmlhttp = yield this.makeRequest("GET", uri, options);
if (xmlhttp.status == 304) {
return false;
}
var libraryVersion = xmlhttp.getResponseHeader('Last-Modified-Version');
if (!libraryVersion) {
throw new Error("Last-Modified-Version not provided");
}
return {
libraryVersion: libraryVersion,
settings: this._parseJSON(xmlhttp.responseText)
};
}),
/**
* @return {Object|false} - An object with 'libraryVersion' and a 'deleted' object, or
* false if 'since' is earlier than the beginning of the delete log
*/
getDeleted: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, since) {
var params = {
target: "deleted",
libraryType: libraryType,
libraryTypeID: libraryTypeID,
since: since || 0
};
var uri = this.buildRequestURI(params);
var xmlhttp = yield this.makeRequest("GET", uri, { successCodes: [200, 409] });
if (xmlhttp.status == 409) {
Zotero.debug(`'since' value '${since}' is earlier than the beginning of the delete log`);
return false;
}
var libraryVersion = xmlhttp.getResponseHeader('Last-Modified-Version');
if (!libraryVersion) {
throw new Error("Last-Modified-Version not provided");
}
return {
libraryVersion: libraryVersion,
deleted: this._parseJSON(xmlhttp.responseText)
};
}),
/**
* Return a promise for a JS object with object keys as keys and version
* numbers as values. By default, returns all objects in the library.
* Additional parameters (such as 'since', 'sincetime', 'libraryVersion')
* can be passed in 'params'.
*
* @param {String} libraryType 'user' or 'group'
* @param {Integer} libraryTypeID userID or groupID
* @param {String} objectType 'item', 'collection', 'search'
* @param {Object} queryParams Query parameters (see buildRequestURI())
* @return {Promise<Object>|false} - Object with 'libraryVersion' and 'results', or false if
* nothing changed since specified library version
*/
getVersions: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, objectType, queryParams) {
var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
var params = {
target: objectTypePlural,
libraryType: libraryType,
libraryTypeID: libraryTypeID,
format: 'versions'
};
if (queryParams) {
for (let i in queryParams) {
params[i] = queryParams[i];
}
}
if (objectType == 'item') {
params.includeTrashed = 1;
}
// TODO: Use pagination
var uri = this.buildRequestURI(params);
var options = {
successCodes: [200, 304]
};
var xmlhttp = yield this.makeRequest("GET", uri, options);
if (xmlhttp.status == 304) {
return false;
}
var libraryVersion = xmlhttp.getResponseHeader('Last-Modified-Version');
if (!libraryVersion) {
throw new Error("Last-Modified-Version not provided");
}
return {
libraryVersion: libraryVersion,
versions: this._parseJSON(xmlhttp.responseText)
};
}),
/**
* Retrieve JSON from API for requested objects
*
* If necessary, multiple API requests will be made.
*
* @param {String} libraryType - 'user', 'group'
* @param {Integer} libraryTypeID - userID or groupID
* @param {String} objectType - 'collection', 'item', 'search'
* @param {String[]} objectKeys - Keys of objects to request
* @return {Array<Promise<Object[]|Error[]>>} - An array of promises for batches of JSON objects
* or Errors for failures
*/
downloadObjects: function (libraryType, libraryTypeID, objectType, objectKeys) {
if (!objectKeys.length) {
return [];
}
// If more than max per request, call in batches
if (objectKeys.length > this.MAX_OBJECTS_PER_REQUEST) {
let allKeys = objectKeys.concat();
let promises = [];
while (true) {
let requestKeys = allKeys.splice(0, this.MAX_OBJECTS_PER_REQUEST)
if (!requestKeys.length) {
break;
}
let promise = this.downloadObjects(
libraryType,
libraryTypeID,
objectType,
requestKeys
)[0];
if (promise) {
promises.push(promise);
}
}
return promises;
}
// Otherwise make request
var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
Zotero.debug("Retrieving " + objectKeys.length + " "
+ (objectKeys.length == 1 ? objectType : objectTypePlural));
var params = {
target: objectTypePlural,
libraryType: libraryType,
libraryTypeID: libraryTypeID,
format: 'json'
};
params[objectType + "Key"] = objectKeys.join(",");
if (objectType == 'item') {
params.includeTrashed = 1;
}
var uri = this.buildRequestURI(params);
return [
this.makeRequest("GET", uri)
.then(function (xmlhttp) {
return this._parseJSON(xmlhttp.responseText)
}.bind(this))
// Return the error without failing the whole chain
.catch(function (e) {
if (e instanceof Zotero.HTTP.UnexpectedStatusException && e.is4xx()) {
Zotero.logError(e);
throw e;
}
Zotero.logError(e);
return e;
})
];
},
uploadObjects: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, method, libraryVersion, objectType, objects) {
if (method != 'POST' && method != 'PATCH') {
throw new Error("Invalid method '" + method + "'");
}
var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
Zotero.debug("Uploading " + objects.length + " "
+ (objects.length == 1 ? objectType : objectTypePlural));
Zotero.debug("Sending If-Unmodified-Since-Version: " + libraryVersion);
var json = JSON.stringify(objects);
var params = {
target: objectTypePlural,
libraryType: libraryType,
libraryTypeID: libraryTypeID
};
var uri = this.buildRequestURI(params);
var xmlhttp = yield this.makeRequest(method, uri, {
headers: {
"Content-Type": "application/json",
"If-Unmodified-Since-Version": libraryVersion
},
body: json,
successCodes: [200, 412]
});
// Avoid logging error from Zotero.HTTP.request() in ConcurrentCaller
if (xmlhttp.status == 412) {
Zotero.debug("Server returned 412: " + xmlhttp.responseText, 2);
throw new Zotero.HTTP.UnexpectedStatusException(xmlhttp);
}
libraryVersion = xmlhttp.getResponseHeader('Last-Modified-Version');
if (!libraryVersion) {
throw new Error("Last-Modified-Version not provided");
}
return {
libraryVersion,
results: this._parseJSON(xmlhttp.responseText)
};
}),
uploadDeletions: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, libraryVersion, objectType, keys) {
var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
Zotero.debug(`Uploading ${keys.length} ${objectType} deletion`
+ (keys.length == 1 ? '' : 's'));
Zotero.debug("Sending If-Unmodified-Since-Version: " + libraryVersion);
var params = {
target: objectTypePlural,
libraryType: libraryType,
libraryTypeID: libraryTypeID
};
if (objectType == 'tag') {
params.tags = keys.join("||");
}
else {
params[objectType + "Key"] = keys.join(",");
}
var uri = this.buildRequestURI(params);
var xmlhttp = yield this.makeRequest("DELETE", uri, {
headers: {
"If-Unmodified-Since-Version": libraryVersion
},
successCodes: [204, 412]
});
// Avoid logging error from Zotero.HTTP.request() in ConcurrentCaller
if (xmlhttp.status == 412) {
Zotero.debug("Server returned 412: " + xmlhttp.responseText, 2);
throw new Zotero.HTTP.UnexpectedStatusException(xmlhttp);
}
libraryVersion = xmlhttp.getResponseHeader('Last-Modified-Version');
if (!libraryVersion) {
throw new Error("Last-Modified-Version not provided");
}
return libraryVersion;
}),
getFullTextVersions: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, since) {
var params = {
libraryType: libraryType,
libraryTypeID: libraryTypeID,
target: "fulltext"
};
if (since) {
params.since = since;
}
// TODO: Use pagination
var uri = this.buildRequestURI(params);
var xmlhttp = yield this.makeRequest("GET", uri);
var libraryVersion = xmlhttp.getResponseHeader('Last-Modified-Version');
if (!libraryVersion) {
throw new Error("Last-Modified-Version not provided");
}
return {
libraryVersion: libraryVersion,
versions: this._parseJSON(xmlhttp.responseText)
};
}),
getFullTextForItem: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, itemKey) {
var params = {
libraryType: libraryType,
libraryTypeID: libraryTypeID,
target: `items/${itemKey}/fulltext`
};
var uri = this.buildRequestURI(params);
var xmlhttp = yield this.makeRequest("GET", uri);
var version = xmlhttp.getResponseHeader('Last-Modified-Version');
if (!version) {
throw new Error("Last-Modified-Version not provided");
}
return {
version,
data: this._parseJSON(xmlhttp.responseText)
};
}),
setFullTextForItem: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, itemKey, data) {
var params = {
libraryType: libraryType,
libraryTypeID: libraryTypeID,
target: `items/${itemKey}/fulltext`
};
var uri = this.buildRequestURI(params);
var xmlhttp = yield this.makeRequest(
"PUT",
uri,
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data),
successCodes: [204],
debug: true
}
);
var libraryVersion = xmlhttp.getResponseHeader('Last-Modified-Version');
if (!libraryVersion) {
throw new Error("Last-Modified-Version not provided");
}
return libraryVersion;
}),
buildRequestURI: function (params) {
var uri = this.baseURL;
switch (params.libraryType) {
case 'publications':
uri += 'users/' + params.libraryTypeID + '/' + params.libraryType;
break;
default:
uri += params.libraryType + 's/' + params.libraryTypeID;
break;
}
if (params.target === undefined) {
throw new Error("'target' not provided");
}
uri += "/" + params.target;
if (params.objectKey) {
uri += "/" + params.objectKey;
}
var queryString = '?';
var queryParamsArray = [];
var queryParamOptions = [
'session',
'format',
'include',
'includeTrashed',
'itemType',
'itemKey',
'collectionKey',
'searchKey',
'tag',
'linkMode',
'start',
'limit',
'sort',
'direction',
'since',
'sincetime'
];
queryParams = {};
for (let option in params) {
let value = params[option];
if (value !== undefined && value !== '' && queryParamOptions.indexOf(option) != -1) {
queryParams[option] = value;
}
}
for (let index in queryParams) {
let value = queryParams[index];
if (Array.isArray(value)) {
value.forEach(function(v, i) {
queryParamsArray.push(encodeURIComponent(index) + '=' + encodeURIComponent(v));
});
}
else {
queryParamsArray.push(encodeURIComponent(index) + '=' + encodeURIComponent(value));
}
}
return uri + (queryParamsArray.length ? "?" + queryParamsArray.join('&') : "");
},
getHeaders: function (headers = {}) {
headers["Zotero-API-Version"] = this.apiVersion;
if (this.apiKey) {
headers["Zotero-API-Key"] = this.apiKey;
}
return headers;
},
makeRequest: Zotero.Promise.coroutine(function* (method, uri, options = {}) {
options.headers = this.getHeaders(options.headers);
options.dontCache = true;
options.foreground = !options.background;
options.responseType = options.responseType || 'text';
var tries = 0;
var failureDelayGenerator = null;
while (true) {
var result = yield this.caller.start(Zotero.Promise.coroutine(function* () {
try {
var xmlhttp = yield Zotero.HTTP.request(method, uri, options);
this._checkBackoff(xmlhttp);
return xmlhttp;
}
catch (e) {
tries++;
if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
this._checkConnection(e.xmlhttp, e.channel);
//this._checkRetry(e.xmlhttp);
if (e.is5xx()) {
Zotero.logError(e);
if (!failureDelayGenerator) {
// Keep trying for up to an hour
failureDelayGenerator = Zotero.Utilities.Internal.delayGenerator(
this.failureDelayIntervals, 60 * 60 * 1000
);
}
let keepGoing = yield failureDelayGenerator.next();
if (!keepGoing) {
Zotero.logError("Failed too many times");
throw lastError;
}
return false;
}
}
throw e;
}
}.bind(this)));
if (result) {
return result;
}
}
}),
_parseJSON: function (json) {
try {
json = JSON.parse(json);
}
catch (e) {
Zotero.debug(e, 1);
Zotero.debug(json, 1);
throw e;
}
return json;
},
/**
* Check connection for certificate errors, interruptions, and empty responses and
* throw an appropriate error
*/
_checkConnection: function (xmlhttp, channel) {
const Ci = Components.interfaces;
if (!xmlhttp.responseText) {
let msg = null;
let dialogButtonText = null;
let dialogButtonCallback = null;
// Check SSL cert
if (channel) {
let secInfo = channel.securityInfo;
if (secInfo instanceof Ci.nsITransportSecurityInfo) {
secInfo.QueryInterface(Ci.nsITransportSecurityInfo);
if ((secInfo.securityState & Ci.nsIWebProgressListener.STATE_IS_INSECURE)
== Ci.nsIWebProgressListener.STATE_IS_INSECURE) {
let url = channel.name;
let ios = Components.classes["@mozilla.org/network/io-service;1"]
.getService(Components.interfaces.nsIIOService);
try {
var uri = ios.newURI(url, null, null);
var host = uri.host;
}
catch (e) {
Zotero.debug(e);
}
let kbURL = 'https://www.zotero.org/support/kb/ssl_certificate_error';
msg = Zotero.getString('sync.storage.error.webdav.sslCertificateError', host);
dialogButtonText = Zotero.getString('general.moreInformation');
dialogButtonCallback = function () {
let wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
.getService(Components.interfaces.nsIWindowMediator);
let win = wm.getMostRecentWindow("navigator:browser");
win.ZoteroPane.loadURI(kbURL, { metaKey: true, shiftKey: true });
};
}
else if ((secInfo.securityState & Ci.nsIWebProgressListener.STATE_IS_BROKEN)
== Ci.nsIWebProgressListener.STATE_IS_BROKEN) {
msg = Zotero.getString('sync.error.sslConnectionError');
}
}
}
if (!msg && xmlhttp.status === 0) {
msg = Zotero.getString('sync.error.checkConnection');
}
if (!msg) {
msg = Zotero.getString('sync.error.emptyResponseServer')
+ Zotero.getString('general.tryAgainLater');
}
throw new Zotero.Error(
msg,
0,
{
dialogButtonText,
dialogButtonCallback
}
);
}
},
_checkBackoff: function (xmlhttp) {
var backoff = xmlhttp.getResponseHeader("Backoff");
if (backoff) {
// Sanity check -- don't wait longer than an hour
if (backoff > 3600) {
// TODO: Update status?
this.caller.pause(backoff * 1000);
}
}
}
}