diff --git a/CHANGELOG.md b/CHANGELOG.md index 244a40c9..4300d649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,10 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -A list of unreleased changes can be found [here](https://github.com/SAP/ui5-server/compare/v0.2.0...HEAD). +A list of unreleased changes can be found [here](https://github.com/SAP/ui5-server/compare/v0.2.1...HEAD). + + +## [v0.2.1] - 2018-07-13 ## [v0.2.0] - 2018-07-12 @@ -53,6 +56,7 @@ A list of unreleased changes can be found [here](https://github.com/SAP/ui5-serv - **Travis:** Add node.js 10 to test matrix [`2881261`](https://github.com/SAP/ui5-server/commit/2881261a05afd737af7c8874b91819a52b8f88df) +[v0.2.1]: https://github.com/SAP/ui5-server/compare/v0.2.0...v0.2.1 [v0.2.0]: https://github.com/SAP/ui5-server/compare/v0.1.2...v0.2.0 [v0.1.2]: https://github.com/SAP/ui5-server/compare/v0.1.1...v0.1.2 [v0.1.1]: https://github.com/SAP/ui5-server/compare/v0.1.0...v0.1.1 diff --git a/README.md b/README.md index 1cd50058..ce9ad465 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,22 @@ If there is none, a new certificate is created and used. **Hint:** If Chrome unintentionally redirects a HTTP-URL to HTTPS, you need to delete the HSTS mapping in [chrome://net-internals/#hsts](chrome://net-internals/#hsts) by entering the domain name (e.g. localhost) and pressing "delete". +## Proxy + +You can proxy existing (OData) backend services to get around Access-Control-Allow-Origin (CORS) errors. +To do so, you can add a hash of proxied paths in your `ui5.yml`. Example (auth is optional): +``` +# ui5.yml +specVersion: '0.1' +metadata: + name: my-awesome-app +type: application +resources: + proxies: + /api/v1: "http://api.of.external.service/v1" + auth: "myproxyusername:myproxypassword" +``` + ## Contributing Please check our [Contribution Guidelines](https://github.com/SAP/ui5-tooling/blob/master/CONTRIBUTING.md). diff --git a/lib/middleware/serveProxies.js b/lib/middleware/serveProxies.js new file mode 100644 index 00000000..3d314f4e --- /dev/null +++ b/lib/middleware/serveProxies.js @@ -0,0 +1,133 @@ +// heavily inspired by https://github.com/SAP/connect-openui5/blob/master/lib/proxy.js +const url = require("url"); +const httpProxy = require("http-proxy"); + +const env = { + noProxy: process.env.NO_PROXY || process.env.no_proxy, + httpProxy: process.env.HTTP_PROXY || process.env.http_proxy, + httpsProxy: process.env.HTTPS_PROXY || process.env.https_proxy +}; + +function getProxyUri(uri) { + if (uri.protocol === "https:" && env.httpsProxy || uri.protocol === "http:" && env.httpProxy) { + if (env.noProxy) { + const canonicalHost = uri.host.replace(/^\.*/, "."); + const port = uri.port || (uri.protocol === "https:" ? "443" : "80"); + + const patterns = env.noProxy.split(","); + for (let i = patterns.length - 1; i >= 0; i--) { + let pattern = patterns[i].trim().toLowerCase(); + + // don"t use a proxy at all + if (pattern === "*") { + return null; + } + + // Remove leading * and make sure to have exact one leading dot (.) + pattern = pattern.replace(/^[*]+/, "").replace(/^\.*/, "."); + + // if host ends with pattern, no proxy should be used + if (canonicalHost.indexOf(pattern) === canonicalHost.length - pattern.length) { + return null; + } + } + } + + if (uri.protocol === "https:" && env.httpsProxy) { + return env.httpsProxy; + } else if (uri.protocol === "http:" && env.httpProxy) { + return env.httpProxy; + } + } + + return null; +} + +function buildRequestUrl(uri) { + let ret = uri.pathname; + if (uri.query) { + ret += "?" + uri.query; + } + return ret; +} + +function createUri(uriParam, proxyDefinitions) { + for (let path in proxyDefinitions) { + if (uriParam.startsWith(path)) { + return url.parse(proxyDefinitions[path] + uriParam.substring(path.length)); + } + } + return null; +} + +/** + * Creates and returns the middleware to serve proxied servers. + * + * @module server/middleware/serveProxies + * @param {Object} proxyDefinitions Contains proxy definitions + * @returns {function} Returns a server middleware closure. + */ +function createMiddleware(proxyDefinitions) { + let proxyServerParameters = {}; + if (proxyDefinitions.auth) + { + proxyServerParameters.auth = proxyDefinitions.auth; + } + let proxy = httpProxy.createProxyServer(proxyServerParameters); + + return function serveResources(req, res, next) { + if (proxyDefinitions == undefined) { + return next(); + } + let uri = createUri(req.url, proxyDefinitions); + if (!uri || !uri.host) { + next(); + return; + } + + // change original request url to target url + req.url = buildRequestUrl(uri); + + // change original host to target host + req.headers.host = uri.host; + + // overwrite response headers + res.orgWriteHead = res.writeHead; + res.writeHead = function(...args) { + // We always filter the secure header to avoid the cookie from + // "not" beeing included in follow up requests in case of the + // proxy is running on HTTP and not HTTPS + let cookies = res.getHeader("set-cookie"); + // array == multiple cookies + if (Array.isArray(cookies)) { + for (let i = 0; i < cookies.length; i++) { + cookies[i] = cookies[i].replace("secure;", ""); + } + } else if (typeof cookies === "string" || cookies instanceof String) { + // single cookie + cookies = cookies.replace("secure;", ""); + } + + if (cookies) { + res.setHeader("set-cookie", cookies); + } + + // call original writeHead function + res.orgWriteHead(args); + }; + + // get proxy for uri (if defined in env vars) + let targetUri = getProxyUri(uri) || uri.protocol + "//" + uri.host; + + // proxy the request + proxy.proxyRequest(req, res, { + target: targetUri + }, function(err) { + if (err) { + next(err); + } + }); + }; +} + +module.exports = createMiddleware; diff --git a/lib/server.js b/lib/server.js index b6cd08d7..e991c0ae 100644 --- a/lib/server.js +++ b/lib/server.js @@ -8,6 +8,7 @@ const serveIndex = require("./middleware/serveIndex"); const discovery = require("./middleware/discovery"); const versionInfo = require("./middleware/versionInfo"); const serveThemes = require("./middleware/serveThemes"); +const serveProxies = require("./middleware/serveProxies"); const csp = require("./middleware/csp"); const ui5connect = require("connect-openui5"); const nonReadRequests = require("./middleware/nonReadRequests"); @@ -90,6 +91,7 @@ function serve(tree, {port, changePortIfInUse = false, h2 = false, key, cert, ac app.use("/proxy", ui5connect.proxy({ secure: false })); + app.use(serveProxies(tree.resources.proxies)); // Handle anything but read operations *before* the serveIndex middleware // as it will reject them with a 405 (Method not allowed) instead of 404 like our old tooling diff --git a/package-lock.json b/package-lock.json index 7628bb1e..7ced01a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@ui5/server", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -269,9 +269,9 @@ } }, "@ui5/builder": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@ui5/builder/-/builder-0.2.0.tgz", - "integrity": "sha512-Z7fNTCCgl+GEkMUxUjVpbG/sKVGR+O2sC6OUYB6l7isuzKi4QIdIkbEgmItsJfMS+Dwv2XdTB/3Cmeka++Gl1Q==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@ui5/builder/-/builder-0.2.1.tgz", + "integrity": "sha512-gEIoO4ocr9cEIrivU0G1S0AL7pprgF6RyXwzTt7ijatCeDPI6byYpzkK3WYwOlrw102Vwg6nZAEtBPTxR8xaKQ==", "requires": { "@ui5/fs": "^0.2.0", "@ui5/logger": "^0.2.0", @@ -1417,6 +1417,11 @@ } } }, + "base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -1567,6 +1572,15 @@ "integrity": "sha1-/vKNqLgROgoNtEMLC2Rntpcws0o=", "dev": true }, + "buffer": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.1.0.tgz", + "integrity": "sha512-YkIRgwsZwJWTnyQrsBTWefizHh+8GYj3kbL1BTiAQ/9pwpino0G7B2gp5tx/FUBqUlvtxV85KNR3mwfAtv15Yw==", + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, "buffer-alloc": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", @@ -2018,31 +2032,24 @@ }, "dependencies": { "mime-db": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.34.0.tgz", - "integrity": "sha1-RS0Oz/XDA0am3B5kseruDTcZ/5o=" + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.35.0.tgz", + "integrity": "sha512-JWT/IcCTsB0Io3AhWUMjRqucrHSPsSf2xKLaRldJVULioggvkJvggZ3VXNNSRkCddE6D+BUI4HEIZIA2OjwIvg==" } } }, "compression": { - "version": "1.7.2", - "resolved": "http://registry.npmjs.org/compression/-/compression-1.7.2.tgz", - "integrity": "sha1-qv+81qr4VLROuygDU9WtFlH1mmk=", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.3.tgz", + "integrity": "sha512-HSjyBG5N1Nnz7tF2+O7A9XUhyjru71/fwgNb7oIsEVHR0WShfs2tIS/EySLgiTe98aOK18YDlMXpzjCXY/n9mg==", "requires": { - "accepts": "~1.3.4", + "accepts": "~1.3.5", "bytes": "3.0.0", - "compressible": "~2.0.13", + "compressible": "~2.0.14", "debug": "2.6.9", "on-headers": "~1.0.1", - "safe-buffer": "5.1.1", + "safe-buffer": "5.1.2", "vary": "~1.1.2" - }, - "dependencies": { - "safe-buffer": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", - "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" - } } }, "concat-map": { @@ -2207,9 +2214,12 @@ } }, "crc": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.5.0.tgz", - "integrity": "sha1-mLi6fUiWZbo5efWbITgTdBAaGWQ=" + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.7.0.tgz", + "integrity": "sha512-ZwmUex488OBjSVOMxnR/dIa1yxisBMJNEi+UxzXpKhax8MPsQtoRQtl5Qgo+W7pcSVkRXa3BEVjaniaWKtvKvw==", + "requires": { + "buffer": "^5.1.0" + } }, "crc32-stream": { "version": "2.0.0", @@ -2750,9 +2760,9 @@ } }, "eslint": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-5.1.0.tgz", - "integrity": "sha512-DyH6JsoA1KzA5+OSWFjg56DFJT+sDLO0yokaPZ9qY0UEmYrPA1gEX/G1MnVkmRDsksG4H1foIVz2ZXXM3hHYvw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-5.3.0.tgz", + "integrity": "sha512-N/tCqlMKkyNvAvLu+zI9AqDasnSLt00K+Hu8kdsERliC9jYEc8ck12XtjvOXrBKu8fK6RrBcN9bat6Xk++9jAg==", "dev": true, "requires": { "ajv": "^6.5.0", @@ -2771,7 +2781,7 @@ "functional-red-black-tree": "^1.0.1", "glob": "^7.1.2", "globals": "^11.7.0", - "ignore": "^3.3.3", + "ignore": "^4.0.2", "imurmurhash": "^0.1.4", "inquirer": "^5.2.0", "is-resolvable": "^1.1.0", @@ -2786,7 +2796,7 @@ "path-is-inside": "^1.0.2", "pluralize": "^7.0.0", "progress": "^2.0.0", - "regexpp": "^1.1.0", + "regexpp": "^2.0.0", "require-uncached": "^1.0.3", "semver": "^5.5.0", "string.prototype.matchall": "^2.0.0", @@ -2848,6 +2858,12 @@ "integrity": "sha512-K8BNSPySfeShBQXsahYB/AbbWruVOTyVpgoIDnl8odPpeSfP2J5QO2oLFFdl2j7GfDCtZj2bMKar2T49itTPCg==", "dev": true }, + "ignore": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.3.tgz", + "integrity": "sha512-Z/vAH2GGIEATQnBVXMclE2IGV6i0GyVngKThcGZ5kHgHMxLo9Ow2+XHRq1aEKEej5vOF1TPJNbvX6J/anT0M7A==", + "dev": true + }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4345,6 +4361,11 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" }, + "ieee754": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.12.tgz", + "integrity": "sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA==" + }, "ignore": { "version": "3.3.10", "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", @@ -5432,16 +5453,16 @@ "optional": true }, "mime-db": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", - "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==" + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.35.0.tgz", + "integrity": "sha512-JWT/IcCTsB0Io3AhWUMjRqucrHSPsSf2xKLaRldJVULioggvkJvggZ3VXNNSRkCddE6D+BUI4HEIZIA2OjwIvg==" }, "mime-types": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", - "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.19.tgz", + "integrity": "sha512-P1tKYHVSZ6uFo26mtnve4HQFE3koh1UWVkp8YUC+ESBHe945xWSoXuHHiGarDqcEZ+whpCDnlNw5LON0kLo+sw==", "requires": { - "mime-db": "~1.33.0" + "mime-db": "~1.35.0" } }, "mimic-fn": { @@ -8425,9 +8446,9 @@ } }, "regexpp": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-1.1.0.tgz", - "integrity": "sha512-LOPw8FpgdQF9etWMaAfG/WRthIdXJGYp4mJ2Jgn/2lpkbod9jPn0t9UqN7AxBOKNfzRbYyVfgc7Vk4t/MpnXgw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.0.tgz", + "integrity": "sha512-g2FAVtR8Uh8GO1Nv5wpxW7VFVwHcCEr4wyA8/MHiRkO8uHoR5ntAA8Uq3P1vvMTX/BeQiRVSpDGLd+Wn5HNOTA==", "dev": true }, "regexpu-core": { diff --git a/package.json b/package.json index 40c45980..99e9c25a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ui5/server", - "version": "0.2.0", + "version": "0.2.1", "description": "UI5 Build and Development Tooling - Server", "author": "SAP SE (https://www.sap.com)", "license": "Apache-2.0", @@ -24,7 +24,7 @@ "unit-watch": "rimraf test/tmp && ava --watch", "unit-nyan": "npm run unit -- --tap | tnyan", "unit-debug": "rimraf test/tmp && cross-env DEBUG=*,-babel,-ava ava", - "unit-inspect": "cross-env DEBUG=*,-babel,-ava node --inspect-brk node_modules/ava/profile.js", + "unit-inspect": "cross-env DEBUG=*,-babel,-ava node $NODE_DEBUG_OPTION --inspect-brk node_modules/ava/profile.js ./test/lib/server.js", "coverage": "nyc npm run unit", "jsdoc": "npm run jsdoc-generate && opn jsdocs/index.html", "jsdoc-generate": "node_modules/.bin/jsdoc -c ./jsdoc.json ./lib/ || (echo 'Error during JSDoc generation! Check log.' && exit 1)", @@ -103,6 +103,7 @@ "etag": "^1.8.1", "express": "^4.16.2", "fresh": "^0.5.2", + "http-proxy": "^1.12.0", "make-dir": "^1.1.0", "mime-types": "^2.1.17", "portscanner": "^2.1.1", diff --git a/test/fixtures/application.proxy/package.json b/test/fixtures/application.proxy/package.json new file mode 100644 index 00000000..f67bccb1 --- /dev/null +++ b/test/fixtures/application.proxy/package.json @@ -0,0 +1,6 @@ +{ + "name": "application.proxy", + "version": "1.0.0", + "description": "Simple SAPUI5 based application - test for ui5-proxy configuration", + "main": "index.html" +} diff --git a/test/fixtures/application.proxy/ui5.yaml b/test/fixtures/application.proxy/ui5.yaml new file mode 100644 index 00000000..cd0db72a --- /dev/null +++ b/test/fixtures/application.proxy/ui5.yaml @@ -0,0 +1,9 @@ +--- +specVersion: "0.1" +type: application +metadata: + name: application.proxy +resources: + proxies: + /api/v1: "http://localhost:3351" + /404: "http://localhost:3351/proxy/nothing" # non existent diff --git a/test/fixtures/application.proxy/webapp/whatever.json b/test/fixtures/application.proxy/webapp/whatever.json new file mode 100644 index 00000000..fd3019ef --- /dev/null +++ b/test/fixtures/application.proxy/webapp/whatever.json @@ -0,0 +1 @@ +{"all": "OK"} diff --git a/test/lib/server.js b/test/lib/server.js index 17517f0d..77ab0c52 100644 --- a/test/lib/server.js +++ b/test/lib/server.js @@ -455,3 +455,42 @@ test("Get index of resources", (t) => { }) ]); }); + +test("the built-in proxy", (t) => { + let port = 3351; + let request = supertest(`http://localhost:${port}`); + return normalizer.generateProjectTree({ + cwd: "./test/fixtures/application.proxy" + }).then((tree) => { + return server.serve(tree, { + port: port + }); + }).then((serveResult) => { + return request.get("/api/v1/whatever.json").then((res) => { + if (res.error) { + t.fail(res.error.text); + } + t.deepEqual(res.statusCode, 200, "Correct HTTP status code"); + t.deepEqual(res.text, "{\"all\": \"OK\"}\n", "API response correct"); + t.pass("Server answered properly."); + }); + }).then((serveResult) => { // direct access; bypassing the proxy + return request.get("/whatever.json").then((res) => { + if (res.error) { + t.fail(res.error.text); + } + t.deepEqual(res.statusCode, 200, "Correct HTTP status code"); + t.deepEqual(res.text, "{\"all\": \"OK\"}\n", "API response correct"); + t.pass("Server answered properly."); + }); + }).then((serveResult) => { // proxy invalid file + return request.get("/404/not.found.json").then((res) => { + if (res.error) { + t.fail(res.error.text); + } + t.is(res.headers["content-type"], "text/html", "Correct content type"); + t.is(/