diff --git a/chrome/content/zotero/xpcom/connector/connector.js b/chrome/content/zotero/xpcom/connector/connector.js index f4ea8bcea4..fe61b481a7 100644 --- a/chrome/content/zotero/xpcom/connector/connector.js +++ b/chrome/content/zotero/xpcom/connector/connector.js @@ -139,16 +139,32 @@ Zotero.Connector = new function() { /** * Sends the XHR to execute an RPC call. * - * @param {String} method RPC method. See documentation above. + * @param {Object} options + * method - method name + * queryString - a querystring to pass on the HTTP call + * httpMethod - GET|POST + * httpHeaders - an object of HTTP headers to send * @param {Object} data RPC data. See documentation above. * @param {Function} callback Function to be called when requests complete. */ - this.callMethod = function(method, data, callback, tab) { + this.callMethod = function(options, data, callback, tab) { // Don't bother trying if not online in bookmarklet if(Zotero.isBookmarklet && this.isOnline === false) { callback(false, 0); return; } + if (typeof options == 'string') { + Zotero.debug('Zotero.Connector.callMethod() now takes an object instead of a string for method. Update your code.'); + options = {method: options}; + } + var method = options.method; + var sendRequest = options.httpMethod == 'GET' ? Zotero.HTTP.doGet : Zotero.HTTP.doPost; + var httpHeaders = Object.assign({ + "Content-Type":"application/json", + "X-Zotero-Version":Zotero.version, + "X-Zotero-Connector-API-Version":CONNECTOR_API_VERSION + }, options.httpHeaders); + var queryString = options.queryString; var newCallback = function(req) { try { @@ -203,13 +219,11 @@ Zotero.Connector = new function() { callback(false, 0); } } else { // Other browsers can use plain doPost - var uri = CONNECTOR_URI+"connector/"+method; - Zotero.HTTP.doPost(uri, JSON.stringify(data), - newCallback, { - "Content-Type":"application/json", - "X-Zotero-Version":Zotero.version, - "X-Zotero-Connector-API-Version":CONNECTOR_API_VERSION - }); + var uri = CONNECTOR_URI+"connector/" + method + '?' + queryString; + if (httpHeaders["Content-Type"] == 'application/json') { + data = JSON.stringify(data); + } + sendRequest(uri, data, newCallback, httpHeaders); } }, diff --git a/chrome/content/zotero/xpcom/http.js b/chrome/content/zotero/xpcom/http.js index 2927a0bea8..bc46e62785 100644 --- a/chrome/content/zotero/xpcom/http.js +++ b/chrome/content/zotero/xpcom/http.js @@ -192,6 +192,10 @@ Zotero.HTTP = new function() { if (!headers["Content-Type"]) { headers["Content-Type"] = "application/x-www-form-urlencoded"; } + else if (headers["Content-Type"] == 'multipart/form-data') { + // Allow XHR to set Content-Type with boundary for multipart/form-data + delete headers["Content-Type"]; + } if (options.compressBody && this.isWriteMethod(method)) { headers['Content-Encoding'] = 'gzip'; diff --git a/chrome/content/zotero/xpcom/server.js b/chrome/content/zotero/xpcom/server.js index e667ca1f19..5cf8b52fd0 100755 --- a/chrome/content/zotero/xpcom/server.js +++ b/chrome/content/zotero/xpcom/server.js @@ -384,21 +384,34 @@ Zotero.Server.DataListener.prototype._processEndpoint = function(method, postDat if(postData && this.contentType) { // check that endpoint supports contentType var supportedDataTypes = endpoint.supportedDataTypes; - if(supportedDataTypes && supportedDataTypes.indexOf(this.contentType) === -1) { + if(supportedDataTypes && supportedDataTypes != '*' + && supportedDataTypes.indexOf(this.contentType) === -1) { + this._requestFinished(this._generateResponse(400, "text/plain", "Endpoint does not support content-type\n")); return; } - // decode JSON or urlencoded post data, and pass through anything else - if(supportedDataTypes && this.contentType === "application/json") { + // decode content-type post data + if(this.contentType === "application/json") { try { decodedData = JSON.parse(postData); } catch(e) { this._requestFinished(this._generateResponse(400, "text/plain", "Invalid JSON provided\n")); return; } - } else if(supportedDataTypes && this.contentType === "application/x-www-form-urlencoded") { + } else if(this.contentType === "application/x-www-form-urlencoded") { decodedData = Zotero.Server.decodeQueryString(postData); + } else if(this.contentType === "multipart/form-data") { + let boundary = /boundary=([^\s]*)/i.exec(this.header); + if (!boundary) { + return this._requestFinished(this._generateResponse(400, "text/plain", "Invalid multipart/form-data provided\n")); + } + boundary = '--' + boundary[1]; + try { + decodedData = this._decodeMultipartData(postData, boundary); + } catch(e) { + return this._requestFinished(this._generateResponse(400, "text/plain", "Invalid multipart/form-data provided\n")); + } } else { decodedData = postData; } @@ -460,6 +473,40 @@ Zotero.Server.DataListener.prototype._requestFinished = function(response) { } } +Zotero.Server.DataListener.prototype._decodeMultipartData = function(data, boundary) { + var contentDispositionRe = /^Content-Disposition:\s*(.*)$/i; + var results = []; + data = data.split(boundary); + // Ignore pre first boundary and post last boundary + data = data.slice(1, data.length-1); + for (let field of data) { + let fieldData = {}; + field = field.trim(); + // Split header and body + let unixHeaderBoundary = field.indexOf("\n\n"); + let windowsHeaderBoundary = field.indexOf("\r\n\r\n"); + if (unixHeaderBoundary < windowsHeaderBoundary && unixHeaderBoundary != -1) { + fieldData.header = field.slice(0, unixHeaderBoundary); + fieldData.body = field.slice(unixHeaderBoundary+2); + } else if (windowsHeaderBoundary != -1) { + fieldData.header = field.slice(0, windowsHeaderBoundary); + fieldData.body = field.slice(windowsHeaderBoundary+4); + } else { + throw new Error('Malformed multipart/form-data body'); + } + + let contentDisposition = contentDispositionRe.exec(fieldData.header); + if (contentDisposition) { + for (let nameVal of contentDisposition[1].split(';')) { + nameVal.split('='); + fieldData[nameVal[0]] = nameVal.length > 1 ? nameVal[1] : null; + } + } + results.push(fieldData); + } + return results; +}; + /** * Endpoints for the HTTP server diff --git a/chrome/content/zotero/xpcom/server_connector.js b/chrome/content/zotero/xpcom/server_connector.js index ac34235906..f54030622b 100644 --- a/chrome/content/zotero/xpcom/server_connector.js +++ b/chrome/content/zotero/xpcom/server_connector.js @@ -586,6 +586,53 @@ Zotero.Server.Connector.Progress.prototype = { } }; +/** + * Translates resources using import translators + * + * Returns: + * - Object[Item] an array of imported items + */ + +Zotero.Server.Connector.Import = function() {}; +Zotero.Server.Endpoints["/connector/import"] = Zotero.Server.Connector.Import; +Zotero.Server.Connector.Import.prototype = { + supportedMethods: ["POST"], + supportedDataTypes: '*', + permitBookmarklet: false, + + init: Zotero.Promise.coroutine(function* (url, data, sendResponseCallback){ + let translate = new Zotero.Translate.Import(); + translate.setString(data); + let translators = yield translate.getTranslators(); + if (!translators || !translators.length) { + return sendResponseCallback(404); + } + translate.setTranslator(translators[0]); + let items = yield translate.translate(); + return sendResponseCallback(201, "application/json", JSON.stringify(items)); + }) +} + +/** + * Install CSL styles + * + * Returns: + * - {name: styleName} + */ + +Zotero.Server.Connector.InstallStyle = function() {}; +Zotero.Server.Endpoints["/connector/installStyle"] = Zotero.Server.Connector.InstallStyle; +Zotero.Server.Connector.InstallStyle.prototype = { + supportedMethods: ["POST"], + supportedDataTypes: '*', + permitBookmarklet: false, + + init: Zotero.Promise.coroutine(function* (url, data, sendResponseCallback){ + let styleName = yield Zotero.Styles.install(data, url.query.origin || null, true); + sendResponseCallback(201, "application/json", JSON.stringify({name: styleName})); + }) +}; + /** * Get code for a translator * diff --git a/chrome/content/zotero/xpcom/style.js b/chrome/content/zotero/xpcom/style.js index 2b2eb95868..5bc4098632 100644 --- a/chrome/content/zotero/xpcom/style.js +++ b/chrome/content/zotero/xpcom/style.js @@ -242,21 +242,22 @@ Zotero.Styles = new function() { * containing the style data * @param {String} origin The origin of the style, either a filename or URL, to be * displayed in dialogs referencing the style + * @param {Boolean} [noPrompt=false] Skip the confirmation prompt */ - this.install = Zotero.Promise.coroutine(function* (style, origin) { - var styleInstalled; + this.install = Zotero.Promise.coroutine(function* (style, origin, noPrompt=false) { + var styleTitle; try { if (style instanceof Components.interfaces.nsIFile) { // handle nsIFiles var url = Services.io.newFileURI(style); var xmlhttp = yield Zotero.HTTP.request("GET", url.spec); - styleInstalled = yield _install(xmlhttp.responseText, style.leafName); + styleTitle = yield _install(xmlhttp.responseText, style.leafName, false, noPrompt); } else { - styleInstalled = yield _install(style, origin); + styleTitle = yield _install(style, origin, false, noPrompt); } } - catch (e) { + catch (error) { // Unless user cancelled, show an alert with the error if(typeof error === "object" && error instanceof Zotero.Exception.UserCancelled) return; if(typeof error === "object" && error instanceof Zotero.Exception.Alert) { @@ -268,6 +269,7 @@ Zotero.Styles = new function() { origin, "styles.install.title", error)).present(); } } + return styleTitle; }); /** @@ -276,19 +278,20 @@ Zotero.Styles = new function() { * @param {String} origin The origin of the style, either a filename or URL, to be * displayed in dialogs referencing the style * @param {Boolean} [hidden] Whether style is to be hidden. + * @param {Boolean} [noPrompt=false] Skip the confirmation prompt * @return {Promise} */ - var _install = Zotero.Promise.coroutine(function* (style, origin, hidden) { + var _install = Zotero.Promise.coroutine(function* (style, origin, hidden, noPrompt=false) { if (!_initialized) yield Zotero.Styles.init(); - var existingFile, destFile, source, styleID + var existingFile, destFile, source; // First, parse style and make sure it's valid XML var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"] .createInstance(Components.interfaces.nsIDOMParser), doc = parser.parseFromString(style, "application/xml"); - styleID = Zotero.Utilities.xpathText(doc, '/csl:style/csl:info[1]/csl:id[1]', + var styleID = Zotero.Utilities.xpathText(doc, '/csl:style/csl:info[1]/csl:id[1]', Zotero.Styles.ns), // Get file name from URL m = /[^\/]+$/.exec(styleID), @@ -361,7 +364,7 @@ Zotero.Styles = new function() { // display a dialog to tell the user we're about to install the style if(hidden) { destFile = destFileHidden; - } else { + } else if (!noPrompt) { if(existingTitle) { var text = Zotero.getString('styles.updateStyle', [existingTitle, title, origin]); } else { @@ -448,6 +451,7 @@ Zotero.Styles = new function() { yield win.Zotero_Preferences.Cite.refreshStylesList(styleID); } } + return existingTitle || title; }); /** diff --git a/test/tests/server_connectorTest.js b/test/tests/server_connectorTest.js index 73e6b68613..c166e6dfd0 100644 --- a/test/tests/server_connectorTest.js +++ b/test/tests/server_connectorTest.js @@ -313,4 +313,62 @@ describe("Connector Server", function () { ); }); }); + + describe('/connector/importStyle', function() { + var endpoint; + + before(function() { + endpoint = connectorServerPath + "/connector/importStyle"; + }); + + it('should reject application/json requests', function* () { + try { + var response = yield Zotero.HTTP.request( + 'POST', + endpoint, + { + headers: { "Content-Type": "application/json" }, + body: '{}' + } + ); + } catch(e) { + assert.instanceOf(e, Zotero.HTTP.UnexpectedStatusException); + assert.equal(e.xmlhttp.status, 400); + } + }); + + it('should import a style with text/x-csl content-type', function* () { + sinon.stub(Zotero.Styles, 'install', function(style) { + var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"] + .createInstance(Components.interfaces.nsIDOMParser), + doc = parser.parseFromString(style, "application/xml"); + + return Zotero.Promise.resolve( + Zotero.Utilities.xpathText(doc, '/csl:style/csl:info[1]/csl:title[1]', + Zotero.Styles.ns) + ); + }); + + var style = ` + +`; + var response = yield Zotero.HTTP.request( + 'POST', + endpoint, + { + headers: { "Content-Type": "text/x-csl" }, + body: style + } + ); + assert.equal(response.status, 201); + assert.equal(response.response, JSON.stringify({name: 'Test1'})); + Zotero.Styles.install.restore(); + }); + }); });