Merge pull request #2951 from AbeJellinek/add-request-endpoint
Add `/connector/request` endpoint
This commit is contained in:
commit
699e46c2f5
4 changed files with 323 additions and 22 deletions
|
@ -1843,3 +1843,103 @@ Zotero.Server.Connector.IEHack.prototype = {
|
||||||
'</head><body></body></html>');
|
'</head><body></body></html>');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an HTTP request from the client. Accepts {@link Zotero.HTTP.request} options and returns a minimal response
|
||||||
|
* object with the same form as the one returned from {@link Zotero.Utilities.Translate#request}.
|
||||||
|
*
|
||||||
|
* Accepts:
|
||||||
|
* method - The request method ('GET', 'POST', etc.)
|
||||||
|
* url - The URL to make the request to. Must be an absolute HTTP(S) URL.
|
||||||
|
* options - See Zotero.HTTP.request() documentation. Differences:
|
||||||
|
* - responseType is always set to 'text'
|
||||||
|
* - successCodes is always set to false (non-2xx status codes will not trigger an error)
|
||||||
|
* Returns:
|
||||||
|
* Response code is always 200. Body contains:
|
||||||
|
* status - The response status code, as a number
|
||||||
|
* headers - An object mapping header names to values
|
||||||
|
* body - The response body, as a string
|
||||||
|
*/
|
||||||
|
Zotero.Server.Connector.Request = function () {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list of allowed hosts. Intentionally hardcoded.
|
||||||
|
*/
|
||||||
|
Zotero.Server.Connector.Request.allowedHosts = ['www.worldcat.org'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For testing: allow disabling validation so we can make requests to the server.
|
||||||
|
*/
|
||||||
|
Zotero.Server.Connector.Request.enableValidation = false;
|
||||||
|
|
||||||
|
Zotero.Server.Endpoints["/connector/request"] = Zotero.Server.Connector.Request;
|
||||||
|
Zotero.Server.Connector.Request.prototype = {
|
||||||
|
supportedMethods: ["POST"],
|
||||||
|
supportedDataTypes: ["application/json"],
|
||||||
|
|
||||||
|
init: async function (req) {
|
||||||
|
let { method, url, options } = req.data;
|
||||||
|
|
||||||
|
if (typeof method !== 'string' || typeof url !== 'string') {
|
||||||
|
return [400, 'text/plain', 'method and url are required and must be strings'];
|
||||||
|
}
|
||||||
|
|
||||||
|
let uri;
|
||||||
|
try {
|
||||||
|
uri = Services.io.newURI(url);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return [400, 'text/plain', 'Invalid URL'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||||
|
return [400, 'text/plain', 'Unsupported scheme'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Zotero.Server.Connector.Request.enableValidation) {
|
||||||
|
if (!Zotero.Server.Connector.Request.allowedHosts.includes(uri.host)) {
|
||||||
|
return [
|
||||||
|
400,
|
||||||
|
'text/plain',
|
||||||
|
'Unsupported URL'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.headers['User-Agent'] || !req.headers['User-Agent'].startsWith('Mozilla/')) {
|
||||||
|
return [400, 'text/plain', 'Unsupported User-Agent'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options = options || {};
|
||||||
|
options.responseType = 'text';
|
||||||
|
options.successCodes = false;
|
||||||
|
|
||||||
|
let xhr;
|
||||||
|
try {
|
||||||
|
xhr = await Zotero.HTTP.request(req.data.method, req.data.url, options);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
if (e instanceof Zotero.HTTP.BrowserOfflineException) {
|
||||||
|
return [503, 'text/plain', 'Client is offline'];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = xhr.status;
|
||||||
|
let headers = {};
|
||||||
|
xhr.getAllResponseHeaders()
|
||||||
|
.trim()
|
||||||
|
.split(/[\r\n]+/)
|
||||||
|
.map(line => line.split(': '))
|
||||||
|
.forEach(parts => headers[parts.shift()] = parts.join(': '));
|
||||||
|
let body = xhr.response;
|
||||||
|
|
||||||
|
return [200, 'application/json', JSON.stringify({
|
||||||
|
status,
|
||||||
|
headers,
|
||||||
|
body
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -117,7 +117,7 @@ Zotero.HTTP = new function() {
|
||||||
* @param {nsIURI|String} url - URL to request
|
* @param {nsIURI|String} url - URL to request
|
||||||
* @param {Object} [options] Options for HTTP request:
|
* @param {Object} [options] Options for HTTP request:
|
||||||
* @param {String} [options.body] - The body of a POST request
|
* @param {String} [options.body] - The body of a POST request
|
||||||
* @param {Object} [options.headers] - Object of HTTP headers to send with the request
|
* @param {Object | Headers} [options.headers] - HTTP headers to send with the request
|
||||||
* @param {Boolean} [options.followRedirects = true] - Object of HTTP headers to send with the
|
* @param {Boolean} [options.followRedirects = true] - Object of HTTP headers to send with the
|
||||||
* request
|
* request
|
||||||
* @param {Zotero.CookieSandbox} [options.cookieSandbox] - The sandbox from which cookies should
|
* @param {Zotero.CookieSandbox} [options.cookieSandbox] - The sandbox from which cookies should
|
||||||
|
@ -343,22 +343,19 @@ Zotero.HTTP = new function() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send headers
|
// Send headers
|
||||||
var headers = {};
|
var headers = new Headers(options?.headers || {});
|
||||||
if (options && options.headers) {
|
|
||||||
Object.assign(headers, options.headers);
|
|
||||||
}
|
|
||||||
var compressedBody = false;
|
var compressedBody = false;
|
||||||
if (options.body) {
|
if (options.body) {
|
||||||
if (!headers["Content-Type"]) {
|
if (!headers.get("Content-Type")) {
|
||||||
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
headers.set("Content-Type", "application/x-www-form-urlencoded");
|
||||||
}
|
}
|
||||||
else if (headers["Content-Type"] == 'multipart/form-data') {
|
else if (headers.get("Content-Type") == 'multipart/form-data') {
|
||||||
// Allow XHR to set Content-Type with boundary for multipart/form-data
|
// Allow XHR to set Content-Type with boundary for multipart/form-data
|
||||||
delete headers["Content-Type"];
|
headers.delete("Content-Type");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.compressBody && this.isWriteMethod(method)) {
|
if (options.compressBody && this.isWriteMethod(method)) {
|
||||||
headers['Content-Encoding'] = 'gzip';
|
headers.set('Content-Encoding', 'gzip');
|
||||||
compressedBody = await Zotero.Utilities.Internal.gzip(options.body);
|
compressedBody = await Zotero.Utilities.Internal.gzip(options.body);
|
||||||
|
|
||||||
let oldLen = options.body.length;
|
let oldLen = options.body.length;
|
||||||
|
@ -368,23 +365,17 @@ Zotero.HTTP = new function() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
if (headers["Zotero-API-Key"]) {
|
if (headers.has("Zotero-API-Key")) {
|
||||||
let dispHeaders = {};
|
let dispHeaders = new Headers(headers);
|
||||||
Object.assign(dispHeaders, headers);
|
dispHeaders.set("Zotero-API-Key", "[Not shown]");
|
||||||
if (dispHeaders["Zotero-API-Key"]) {
|
Zotero.debug({ ...dispHeaders.entries() });
|
||||||
dispHeaders["Zotero-API-Key"] = "[Not shown]";
|
|
||||||
}
|
|
||||||
Zotero.debug(dispHeaders);
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Zotero.debug(headers);
|
Zotero.debug({ ...headers.entries() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (var header in headers) {
|
for (var [header, value] of headers) {
|
||||||
// Convert numbers to string to make Sinon happy
|
// Convert numbers to string to make Sinon happy
|
||||||
let value = typeof headers[header] == 'number'
|
|
||||||
? headers[header].toString()
|
|
||||||
: headers[header]
|
|
||||||
xmlhttp.setRequestHeader(header, value);
|
xmlhttp.setRequestHeader(header, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -45,6 +45,20 @@ describe("Zotero.HTTP", function () {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
httpd.registerPathHandler(
|
||||||
|
'/requireJSON',
|
||||||
|
{
|
||||||
|
handle(request, response) {
|
||||||
|
if (request.getHeader('Content-Type') == 'application/json') {
|
||||||
|
response.setStatusLine(null, 200, "OK");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
response.setStatusLine(null, 400, "Bad Request");
|
||||||
|
}
|
||||||
|
response.write('JSON required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
|
@ -124,6 +138,20 @@ describe("Zotero.HTTP", function () {
|
||||||
server.respond();
|
server.respond();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should process headers case insensitively", async function () {
|
||||||
|
Zotero.HTTP.mock = null;
|
||||||
|
var req = await Zotero.HTTP.request(
|
||||||
|
'GET',
|
||||||
|
baseURL + 'requireJSON',
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(req.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
describe("Retries", function () {
|
describe("Retries", function () {
|
||||||
var spy;
|
var spy;
|
||||||
var delayStub;
|
var delayStub;
|
||||||
|
|
|
@ -2647,4 +2647,186 @@ describe("Connector Server", function () {
|
||||||
assert.equal(item.libraryID, Zotero.Libraries.userLibraryID);
|
assert.equal(item.libraryID, Zotero.Libraries.userLibraryID);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('/connector/request', function () {
|
||||||
|
let endpoint;
|
||||||
|
|
||||||
|
before(function () {
|
||||||
|
endpoint = connectorServerPath + '/connector/request';
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Zotero.Server.Connector.Request.enableValidation = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(function () {
|
||||||
|
Zotero.Server.Connector.Request.enableValidation = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject GET requests', async function () {
|
||||||
|
let req = await Zotero.HTTP.request(
|
||||||
|
'GET',
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
method: 'GET',
|
||||||
|
url: 'https://www.example.com/'
|
||||||
|
}),
|
||||||
|
successCodes: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(req.status, 400);
|
||||||
|
assert.include(req.responseText, 'Endpoint does not support method');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not make requests to arbitrary hosts', async function () {
|
||||||
|
let req = await Zotero.HTTP.request(
|
||||||
|
'POST',
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
method: 'GET',
|
||||||
|
url: `http://localhost:${Zotero.Prefs.get('httpServer.port')}/`
|
||||||
|
}),
|
||||||
|
successCodes: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(req.status, 400);
|
||||||
|
assert.include(req.responseText, 'Unsupported URL');
|
||||||
|
|
||||||
|
req = await Zotero.HTTP.request(
|
||||||
|
'POST',
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
method: 'GET',
|
||||||
|
url: `http://www.example.com/`
|
||||||
|
}),
|
||||||
|
successCodes: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(req.status, 400);
|
||||||
|
assert.include(req.responseText, 'Unsupported URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject requests with non-Mozilla/ user agents', async function () {
|
||||||
|
let req = await Zotero.HTTP.request(
|
||||||
|
'POST',
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'user-agent': 'BadBrowser/1.0'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
method: 'GET',
|
||||||
|
url: `https://www.worldcat.org/api/nonexistent`
|
||||||
|
}),
|
||||||
|
successCodes: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(req.status, 400);
|
||||||
|
assert.include(req.responseText, 'Unsupported User-Agent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow a request to an allowed host', async function () {
|
||||||
|
let stub = sinon.stub(Zotero.HTTP, 'request');
|
||||||
|
// First call: call original
|
||||||
|
stub.callThrough();
|
||||||
|
// Second call (call from within /connector/request handler): return the following
|
||||||
|
stub.onSecondCall().returns({
|
||||||
|
status: 200,
|
||||||
|
getAllResponseHeaders: () => '',
|
||||||
|
response: 'it went through'
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = await Zotero.HTTP.request(
|
||||||
|
'POST',
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
method: 'GET',
|
||||||
|
url: `https://www.worldcat.org/api/nonexistent`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(req.status, 200);
|
||||||
|
assert.equal(JSON.parse(req.responseText).body, 'it went through');
|
||||||
|
|
||||||
|
stub.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return response in translator request() format with lowercase headers', async function () {
|
||||||
|
let testEndpointPath = '/test/header';
|
||||||
|
|
||||||
|
httpd.registerPathHandler(
|
||||||
|
testEndpointPath,
|
||||||
|
{
|
||||||
|
handle: function (request, response) {
|
||||||
|
response.setStatusLine(null, 200, 'OK');
|
||||||
|
response.setHeader('X-Some-Header', 'Header value');
|
||||||
|
response.write('body');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Zotero.Server.Connector.Request.enableValidation = false;
|
||||||
|
let req = await Zotero.HTTP.request(
|
||||||
|
'POST',
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
method: 'GET',
|
||||||
|
url: testServerPath + testEndpointPath
|
||||||
|
}),
|
||||||
|
responseType: 'json'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(req.response.status, 200);
|
||||||
|
assert.equal(req.response.headers['x-some-header'], 'Header value');
|
||||||
|
assert.equal(req.response.body, 'body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set Referer', async function () {
|
||||||
|
let testEndpointPath = '/test/referer';
|
||||||
|
let referer = 'https://www.example.com/';
|
||||||
|
|
||||||
|
httpd.registerPathHandler(
|
||||||
|
testEndpointPath,
|
||||||
|
{
|
||||||
|
handle: function (request, response) {
|
||||||
|
assert.equal(request.getHeader('Referer'), referer);
|
||||||
|
response.setStatusLine(null, 200, 'OK');
|
||||||
|
response.write('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Zotero.Server.Connector.Request.enableValidation = false;
|
||||||
|
let req = await Zotero.HTTP.request(
|
||||||
|
'POST',
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
method: 'GET',
|
||||||
|
url: testServerPath + testEndpointPath,
|
||||||
|
options: {
|
||||||
|
headers: {
|
||||||
|
Referer: referer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(JSON.parse(req.response).status, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue