diff --git a/integration/test/ParseServerTest.js b/integration/test/ParseServerTest.js index 1554d47b9..b16f36405 100644 --- a/integration/test/ParseServerTest.js +++ b/integration/test/ParseServerTest.js @@ -1,5 +1,8 @@ 'use strict'; +const http = require('http'); +const Parse = require('../../node'); + describe('ParseServer', () => { it('can reconfigure server', async () => { let parseServer = await reconfigureServer({ serverURL: 'www.google.com' }); @@ -34,4 +37,21 @@ describe('ParseServer', () => { await object.save(); expect(object.id).toBeDefined(); }); + + it('can forward redirect', async () => { + const serverURL = Parse.serverURL; + const redirectServer = http.createServer(function(_, res) { + res.writeHead(301, { Location: serverURL }); + res.end(); + }).listen(8080); + Parse.CoreManager.set('SERVER_URL', 'http://localhost:8080/api'); + const object = new TestObject({ foo: 'bar' }); + await object.save(); + const query = new Parse.Query(TestObject); + const result = await query.get(object.id); + expect(result.id).toBe(object.id); + expect(result.get('foo')).toBe('bar'); + Parse.serverURL = serverURL; + redirectServer.close(); + }); }); diff --git a/src/RESTController.ts b/src/RESTController.ts index 2f6cf5cac..251878e10 100644 --- a/src/RESTController.ts +++ b/src/RESTController.ts @@ -54,6 +54,16 @@ if (typeof XDomainRequest !== 'undefined' && !('withCredentials' in new XMLHttpR useXDomainRequest = true; } +function getPath(base: string, pathname: string) { + if (base.endsWith('/')) { + base = base.slice(0, -1); + } + if (!pathname.startsWith('/')) { + pathname = '/' + pathname; + } + return base + pathname; +} + function ajaxIE9(method: string, url: string, data: any, _headers?: any, options?: FullOptions) { return new Promise((resolve, reject) => { // @ts-ignore @@ -140,6 +150,7 @@ const RESTController = { method, headers, signal, + redirect: 'manual', }; if (data) { fetchOptions.body = data; @@ -189,6 +200,14 @@ const RESTController = { } else if (status >= 400 && status < 500) { const error = await response.json(); promise.reject(error); + } else if ([301, 302, 303, 307, 308].includes(status)) { + const location = response.headers.get('location'); + promise.resolve({ + status, + location, + method: status === 303 ? 'GET' : method, + dropBody: status === 303, + }); } else if (status >= 500 || status === 0) { // retry on 5XX or library error if (++attempts < CoreManager.get('REQUEST_ATTEMPT_LIMIT')) { @@ -221,12 +240,7 @@ const RESTController = { request(method: string, path: string, data: any, options?: RequestOptions) { options = options || {}; - let url = CoreManager.get('SERVER_URL'); - if (url[url.length - 1] !== '/') { - url += '/'; - } - url += path; - + const url = getPath(CoreManager.get('SERVER_URL'), path); const payload: Partial = {}; if (data && typeof data === 'object') { for (const k in data) { @@ -302,15 +316,31 @@ const RESTController = { } const payloadString = JSON.stringify(payload); - return RESTController.ajax(method, url, payloadString, {}, options).then( - ({ response, status, headers }) => { - if (options.returnStatus) { - return { ...response, _status: status, _headers: headers }; - } else { - return response; + return RESTController.ajax(method, url, payloadString, {}, options).then(async (result) => { + if (result.location) { + let newURL = getPath(result.location, path); + let newMethod = result.method; + let newBody = result.dropBody ? undefined : payloadString; + + // Follow up to 5 redirects to avoid loops + for (let i = 0; i < 5; i += 1) { + const r = await RESTController.ajax(newMethod, newURL, newBody, {}, options); + if (!r.location) { + result = r; + break; + } + newURL = getPath(r.location, path); + newMethod = r.method; + newBody = r.dropBody ? undefined : payloadString; } } - ); + const { response, status, headers } = result; + if (options.returnStatus) { + return { ...response, _status: status, _headers: headers }; + } else { + return response; + } + }); }) .catch(RESTController.handleError); }, diff --git a/src/__tests__/RESTController-test.js b/src/__tests__/RESTController-test.js index f3c3b7817..947467ee5 100644 --- a/src/__tests__/RESTController-test.js +++ b/src/__tests__/RESTController-test.js @@ -433,4 +433,80 @@ describe('RESTController', () => { _SessionToken: '1234', }); }); + + it('follows HTTP redirects for batch requests when using a custom SERVER_URL', async () => { + // Configure a reverse-proxy style SERVER_URL + CoreManager.set('SERVER_URL', 'http://test.host/api'); + + // Prepare a minimal batch payload + const batchData = { + requests: [{ + method: 'POST', + path: '/classes/TestObject', + body: { foo: 'bar' } + }] + }; + + // First response: 301 redirect to /parse/batch; second: successful response + mockFetch( + [ + { status: 301, response: {} }, + { status: 200, response: { success: true } } + ], + { location: 'http://test.host/parse/' } + ); + + // Issue the batch request + const result = await RESTController.request('POST', 'batch', batchData); + + // We expect two fetch calls: one to the original URL, then one to the Location header + expect(fetch.mock.calls.length).toBe(2); + expect(fetch.mock.calls[0][0]).toEqual('http://test.host/api/batch'); + expect(fetch.mock.calls[1][0]).toEqual('http://test.host/parse/batch'); + + // The final result should be the JSON from the second (successful) response + expect(result).toEqual({ success: true }); + + // Clean up the custom SERVER_URL + CoreManager.set('SERVER_URL', 'https://api.parse.com/1'); + }); + + it('follows multiple HTTP redirects', async () => { + // Configure a reverse-proxy style SERVER_URL + CoreManager.set('SERVER_URL', 'http://test.host/api'); + + // Prepare a minimal batch payload + const batchData = { + requests: [{ + method: 'POST', + path: '/classes/TestObject', + body: { foo: 'bar' } + }] + }; + + // First response: 301 redirect to /parse/batch; second: successful response + mockFetch( + [ + { status: 301, response: {} }, + { status: 301, response: {} }, + { status: 200, response: { success: true } } + ], + { location: 'http://test.host/parse/' } + ); + + // Issue the batch request + const result = await RESTController.request('POST', 'batch', batchData); + + // We expect three fetch calls: one to the original URL, then two to the Location header + expect(fetch.mock.calls.length).toBe(3); + expect(fetch.mock.calls[0][0]).toEqual('http://test.host/api/batch'); + expect(fetch.mock.calls[1][0]).toEqual('http://test.host/parse/batch'); + expect(fetch.mock.calls[2][0]).toEqual('http://test.host/parse/batch'); + + // The final result should be the JSON from the second (successful) response + expect(result).toEqual({ success: true }); + + // Clean up the custom SERVER_URL + CoreManager.set('SERVER_URL', 'https://api.parse.com/1'); + }); });