diff --git a/cli/package.json b/cli/package.json index 663916b58..666b4b685 100644 --- a/cli/package.json +++ b/cli/package.json @@ -42,11 +42,13 @@ "fs-extra": "^8.1.0", "gaze": "^1.1.3", "handlebars": "^4.3.3", + "http-proxy": "^1.18.1", "i18next-conv": "^9", "i18next-scanner": "^2.10.3", "inquirer": "^7.3.3", "jest-cli": "^24.9.0", "lodash": "^4.17.11", + "node-http-proxy-json": "^0.1.9", "parse-author": "^2.0.0", "parse-gitignore": "^1.0.1", "styled-jsx": "<3.3.3", diff --git a/cli/src/commands/start.js b/cli/src/commands/start.js index 0f2ab4819..393489ab7 100644 --- a/cli/src/commands/start.js +++ b/cli/src/commands/start.js @@ -7,6 +7,7 @@ const i18n = require('../lib/i18n') const loadEnvFiles = require('../lib/loadEnvFiles') const parseConfig = require('../lib/parseConfig') const makePaths = require('../lib/paths') +const createProxyServer = require('../lib/proxy') const { compileServiceWorker } = require('../lib/pwa') const makeShell = require('../lib/shell') const { validatePackage } = require('../lib/validatePackage') @@ -18,6 +19,8 @@ const handler = async ({ force, port = process.env.PORT || defaultPort, shell: shellSource, + proxy, + proxyPort, }) => { const paths = makePaths(cwd) @@ -36,6 +39,29 @@ const handler = async ({ process.exit(1) } + const newPort = await detectPort(port) + + if (proxy) { + const newProxyPort = await detectPort(proxyPort) + const proxyBaseUrl = `http://localhost:${newProxyPort}` + + reporter.print('') + reporter.info('Starting proxy server...') + reporter.print( + `The proxy for ${chalk.bold( + proxy + )} is now available on port ${newProxyPort}` + ) + reporter.print('') + + createProxyServer({ + target: proxy, + baseUrl: proxyBaseUrl, + port: newProxyPort, + shellPort: newPort, + }) + } + await exitOnCatch( async () => { if (!(await validatePackage({ config, paths, offerFix: false }))) { @@ -77,7 +103,6 @@ const handler = async ({ reporter.info('Generating manifests...') await generateManifests(paths, config, process.env.PUBLIC_URL) - const newPort = await detectPort(port) if (String(newPort) !== String(port)) { reporter.print('') reporter.warn( @@ -123,6 +148,16 @@ const command = { type: 'number', description: 'The port to use when running the development server', }, + proxy: { + alias: 'P', + type: 'string', + description: 'The remote DHIS2 instance the proxy should point to', + }, + proxyPort: { + type: 'number', + description: 'The port to use when running the proxy', + default: 8080, + }, }, handler, } diff --git a/cli/src/lib/proxy.js b/cli/src/lib/proxy.js new file mode 100644 index 000000000..4ff78ea75 --- /dev/null +++ b/cli/src/lib/proxy.js @@ -0,0 +1,127 @@ +const url = require('url') +const { reporter } = require('@dhis2/cli-helpers-engine') +const httpProxy = require('http-proxy') +const _ = require('lodash') +const transformProxyResponse = require('node-http-proxy-json') + +const stripCookieSecure = cookie => { + return cookie + .split(';') + .filter(v => v.trim().toLowerCase() !== 'secure') + .join('; ') +} + +const rewriteLocation = ({ location, target, baseUrl }) => { + const parsedLocation = url.parse(location) + const parsedTarget = url.parse(target) + const parsedBaseUrl = url.parse(baseUrl) + + if ( + parsedLocation.host === parsedTarget.host && + parsedLocation.pathname.startsWith(parsedTarget.pathname) + ) { + return url.format({ + ...parsedBaseUrl, + pathname: parsedLocation.pathname.replace( + parsedTarget.pathname, + '' + ), + search: parsedLocation.search, + }) + } + return location +} + +const isUrl = string => { + try { + const { protocol } = new URL(string) + return protocol === 'http:' || protocol === 'https:' + } catch (error) { + return false + } +} + +const transformJsonResponse = (res, { target, baseUrl }) => { + switch (typeof res) { + case 'string': + if (isUrl(res)) { + return rewriteLocation({ location: res, target, baseUrl }) + } + return res + case 'object': + if (Array.isArray(res)) { + return res.map(r => + transformJsonResponse(r, { target, baseUrl }) + ) + } + return _.transform( + res, + (result, value, key) => { + result[key] = transformJsonResponse(value, { + target, + baseUrl, + }) + }, + {} + ) + default: + return res + } +} + +exports = module.exports = ({ target, baseUrl, port, shellPort }) => { + const proxyServer = httpProxy.createProxyServer({ + target, + changeOrigin: true, + secure: false, + protocolRewrite: 'http', + cookieDomainRewrite: '', + cookiePathRewrite: '/', + }) + + proxyServer.on('proxyRes', (proxyRes, req, res) => { + if (proxyRes.headers['access-control-allow-origin']) { + res.setHeader( + 'access-control-allow-origin', + `http://localhost:${shellPort}` + ) + } + + if (proxyRes.headers.location) { + proxyRes.headers.location = rewriteLocation({ + location: proxyRes.headers.location, + target, + baseUrl, + }) + } + + const sc = proxyRes.headers['set-cookie'] + if (Array.isArray(sc)) { + proxyRes.headers['set-cookie'] = sc.map(stripCookieSecure) + } + + if ( + proxyRes.headers['content-type'] && + proxyRes.headers['content-type'].includes('application/json') + ) { + transformProxyResponse(res, proxyRes, body => { + if (body) { + return transformJsonResponse(body, { + target, + baseUrl, + }) + } + return body + }) + } + }) + + proxyServer.on('error', error => { + reporter.warn(error) + }) + + proxyServer.listen(port) +} + +exports.rewriteLocation = rewriteLocation +exports.transformJsonResponse = transformJsonResponse diff --git a/cli/src/lib/proxy.test.js b/cli/src/lib/proxy.test.js new file mode 100644 index 000000000..291e89e03 --- /dev/null +++ b/cli/src/lib/proxy.test.js @@ -0,0 +1,91 @@ +const { rewriteLocation, transformJsonResponse } = require('./proxy') + +describe('transformJsonResponse', () => { + it('rewrites URLs in responses if they match the proxy target', () => { + const transformedResponse = transformJsonResponse( + { + a: { + b: { + c: 'https://play.dhis2.org/dev/api/endpoint', + }, + }, + }, + { + target: 'https://play.dhis2.org/dev', + baseUrl: 'http://localhost:8080', + } + ) + + expect(transformedResponse.a.b.c).toBe( + 'http://localhost:8080/api/endpoint' + ) + }) +}) + +describe('rewriteLocation', () => { + it('rewrites locations if they match the proxy target', () => { + const baseUrl = 'http://localhost:8080' + + expect( + rewriteLocation({ + location: 'https://play.dhis2.org/dev/login.action', + target: 'https://play.dhis2.org/dev', + baseUrl, + }) + ).toBe(`${baseUrl}/login.action`) + + expect( + rewriteLocation({ + location: 'https://play.dhis2.org/dev/page?param=value', + target: 'https://play.dhis2.org/dev', + baseUrl, + }) + ).toBe(`${baseUrl}/page?param=value`) + + expect( + rewriteLocation({ + location: 'https://server.com:1234', + target: 'https://server.com:5678', + baseUrl, + }) + ).toBe('https://server.com:1234') + + expect( + rewriteLocation({ + location: 'https://server.com', + target: 'http://server.com', + baseUrl, + }) + ).toBe(baseUrl) + + expect( + rewriteLocation({ + location: 'https://play.dhis2.org/dev/api/dev', + target: 'https://play.dhis2.org/dev', + baseUrl, + }) + ).toBe(`${baseUrl}/api/dev`) + }) + + it('does not rewrite locations if they do not match the proxy target', () => { + ;[ + { + location: 'https://example.com/path', + target: 'https://play.dhis2.org/dev', + baseUrl: 'http://localhost:8080', + }, + { + location: 'https://play.dhis2.org/2.35dev', + target: 'https://play.dhis2.org/dev', + baseUrl: 'http://localhost:8080', + }, + { + location: 'http://server.com:1234', + target: 'http://server.com:5678', + baseUrl: 'http://localhost:8080', + }, + ].forEach(args => { + expect(rewriteLocation(args)).toBe(args.location) + }) + }) +}) diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 9ce75f30d..073c31705 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -21,5 +21,6 @@ - [**PWA**](pwa/pwa) - [**Architecture**](architecture) - [**Troubleshooting**](troubleshooting.md) +- [**Proxy**](proxy.md) -   - [Changelog](CHANGELOG.md) diff --git a/docs/proxy.md b/docs/proxy.md new file mode 100644 index 000000000..ccc649f5f --- /dev/null +++ b/docs/proxy.md @@ -0,0 +1,14 @@ +# Proxy + +When developing against a remote instance, some browsers may block cookies due +to the cross-site nature of requests. + +As a workaround, the [`start`](scripts/start.md) command provides a `--proxy` +option. The value of this option is the remote instance that a local proxy will +route requests to. + +## Usage + +``` +d2-app-scripts start --proxy +``` diff --git a/docs/scripts/start.md b/docs/scripts/start.md index 0d1fbb646..696f8c6be 100644 --- a/docs/scripts/start.md +++ b/docs/scripts/start.md @@ -21,4 +21,6 @@ Global Options: Options: --cwd working directory to use (defaults to cwd) --port, -p The port to use when running the development server [number] + --proxy, -P The remote DHIS2 instance the proxy should point to [string] + --proxyPort The port to use when running the proxy [number] [default: 8080] ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2d6225176..70669f504 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -8,8 +8,11 @@ A DHIS2 application needs to talk to a [DHIS2 server](https://www.dhis2.org/down You should specify the fully-qualified base URL of a running DHIS2 server (including the `http://` or `https://` protocol) in the **Server** field of the login dialog. -Make sure that the the URL of your application (`localhost:3000` in this case) is included in the DHIS2 server's CORS whitelist (System Settings -> Access). Also ensure that the server -you specify doesn't have any domain-restricting proxy settings (such as the `SameSite=Lax`). +Make sure that the the URL of your application (`localhost:3000` in this case) +is included in the DHIS2 server's CORS whitelist (System Settings -> Access). If +your server or browser has domain-restricting settings (such as the +`SameSite=Lax`), the [`--proxy`](proxy.md) option of the +[`start`](scripts/start.md) command may be helpful. For testing purposes, `https://play.dhis2.org/dev` will NOT work because it is configured for secure same-site access, while `https://debug.dhis2.org/dev` should work. diff --git a/yarn.lock b/yarn.lock index c9cb6b663..491885010 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5850,6 +5850,11 @@ buffer@^5.1.0, buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +bufferhelper@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/bufferhelper/-/bufferhelper-0.2.1.tgz#fa74a385724a58e242f04ad6646c2366f83b913e" + integrity sha1-+nSjhXJKWOJC8ErWZGwjZvg7kT4= + builtin-modules@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.1.0.tgz#aad97c15131eb76b65b50ef208e7584cd76a7484" @@ -6560,7 +6565,7 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= -concat-stream@^1.5.0: +concat-stream@^1.5.0, concat-stream@^1.5.1: version "1.6.2" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== @@ -9746,6 +9751,15 @@ http-proxy@^1.17.0: follow-redirects "^1.0.0" requires-port "^1.0.0" +http-proxy@^1.18.1: + version "1.18.1" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== + dependencies: + eventemitter3 "^4.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + http-signature@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" @@ -12679,6 +12693,14 @@ node-gettext@^2.0.0: dependencies: lodash.get "^4.4.2" +node-http-proxy-json@^0.1.9: + version "0.1.9" + resolved "https://registry.yarnpkg.com/node-http-proxy-json/-/node-http-proxy-json-0.1.9.tgz#5e744138c189ebd7e0105fe92d035a5486478cd4" + integrity sha512-WrKAR/y09BWaz5WqgbxuE6D/XsdhQFkLkSdnRk0a5uBKSINtApMV085MN7JMh+stiyBBltvgSR9SYVIZIpKKKQ== + dependencies: + bufferhelper "^0.2.1" + concat-stream "^1.5.1" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"