diff --git a/package-lock.json b/package-lock.json index 80b9ddd65..4f34ec14a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4597,6 +4597,11 @@ "cssom": "0.3.x" } }, + "cuint": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz", + "integrity": "sha1-QICG1AlVDCYxFVYZ6fp7ytw7mRs=" + }, "currently-unhandled": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", @@ -15278,6 +15283,18 @@ "uniqs": "^2.0.0" } }, + "postcss-url": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/postcss-url/-/postcss-url-8.0.0.tgz", + "integrity": "sha512-E2cbOQ5aii2zNHh8F6fk1cxls7QVFZjLPSrqvmiza8OuXLzIpErij8BDS5Y3STPfJgpIMNCPEr8JlKQWEoozUw==", + "requires": { + "mime": "^2.3.1", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.0", + "postcss": "^7.0.2", + "xxhashjs": "^0.2.1" + } + }, "postcss-value-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", @@ -19540,6 +19557,14 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" }, + "xxhashjs": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/xxhashjs/-/xxhashjs-0.2.2.tgz", + "integrity": "sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==", + "requires": { + "cuint": "^0.2.2" + } + }, "y18n": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", diff --git a/package.json b/package.json index c4be63467..1d5a0195c 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,9 @@ "openwhisk": "3.19.0", "opn": "6.0.0", "parcel-bundler": "1.12.3", + "postcss": "^7.0.14", + "postcss-url": "^8.0.0", + "postcss-value-parser": "^3.3.1", "progress": "2.0.1", "request": "2.87.0", "request-promise-native": "1.0.7", diff --git a/src/openwhisk/static.js b/src/openwhisk/static.js index f1c0e4a0a..e3a04b5cc 100644 --- a/src/openwhisk/static.js +++ b/src/openwhisk/static.js @@ -13,6 +13,12 @@ const request = require('request-promise-native'); const crypto = require('crypto'); const mime = require('mime-types'); +const postcss = require('postcss'); +const postcssurl = require('postcss-url'); +const parser = require('postcss-value-parser'); + +const { space } = postcss.list; +const uri = require('uri-js'); /* eslint-disable no-console */ // one megabyte openwhisk limit + 20% Base64 inflation + safety padding @@ -74,17 +80,89 @@ function isBinary(type) { return true; } +function isCSS(type) { + return type === 'text/css'; +} + +function isJavaScript(type) { + return type.match(/(text|application)\/(x-)?(javascript|ecmascript)/); +} + +function rewriteImports(tree) { + tree.walkAtRules('import', (rule) => { + if (rule.name === 'import') { + const [url, queries] = space(rule.params); + const parsedurl = parser(url); + if (parsedurl.nodes + && parsedurl.nodes.length === 1 + && parsedurl.nodes[0].value === 'url' + && parsedurl.nodes[0].nodes + && parsedurl.nodes[0].nodes.length === 1 + && parsedurl.nodes[0].nodes[0].type === 'string' + && typeof parsedurl.nodes[0].nodes[0].value === 'string' + && typeof parsedurl.nodes[0].nodes[0].quote === 'string') { + const importuri = uri.parse(parsedurl.nodes[0].nodes[0].value); + const { quote } = parsedurl.nodes[0].nodes[0]; + if (importuri.reference === 'relative' && !importuri.query) { + rule.replaceWith(postcss.atRule({ + name: 'import', + params: `url(${quote}${quote}) ${queries}`, + })); + } + } else if (parsedurl.nodes + && parsedurl.nodes[0].type === 'string' + && typeof parsedurl.nodes[0].value === 'string' + && typeof parsedurl.nodes[0].quote === 'string') { + const importuri = uri.parse(parsedurl.nodes[0].value); + const { quote } = parsedurl.nodes[0]; + if (importuri.reference === 'relative' && !importuri.query) { + rule.replaceWith(postcss.atRule({ + name: 'import', + params: `${quote}${quote} ${queries}`, + })); + } + } + } + }); + return tree; +} + +function rewriteCSS(css) { + const processor = postcss() + .use(rewriteImports) + .use(postcssurl({ + url: (asset) => { + // TODO pass in request URL and make it absolute. + if (asset.search === '' && asset.absolutePath !== '.' && asset.relativePath !== '.') { + return ``; + } + return asset.url; + }, + })); + return processor.process(css, { from: undefined }).then(result => result.css); +} + +function rewriteJavaScript(javascript) { + return javascript; +} + function isJSON(type) { return !!type.match(/json/); } -function getBody(type, responsebody) { +function getBody(type, responsebody, esi = false) { if (isBinary(type)) { return Buffer.from(responsebody).toString('base64'); } if (isJSON(type)) { return JSON.parse(responsebody); } + if (esi && isCSS(type)) { + return rewriteCSS(responsebody.toString()); + } + if (esi && isJavaScript(type)) { + return rewriteJavaScript(responsebody.toString()); + } return responsebody.toString(); } @@ -103,7 +181,7 @@ function staticBase(owner, repo, entry, ref, strain = 'default') { return `__HLX/${owner}/${repo}/${strain}/${ref}/${entry}/DIST__`; } -function deliverPlain(owner, repo, ref, entry, root) { +function deliverPlain(owner, repo, ref, entry, root, esi = false) { const cleanentry = (`${root}/${entry}`).replace(/^\//, '').replace(/[/]+/g, '/'); console.log('deliverPlain()', owner, repo, ref, cleanentry); const url = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${cleanentry}`; @@ -116,12 +194,12 @@ function deliverPlain(owner, repo, ref, entry, root) { encoding: null, }; - return request.get(rawopts).then((response) => { + return request.get(rawopts).then(async (response) => { const type = mime.lookup(cleanentry) || 'application/octet-stream'; const size = parseInt(response.headers['content-length'], 10); console.log('size', size); if (size < REDIRECT_LIMIT) { - const body = getBody(type, response.body); + const body = await getBody(type, response.body, esi); console.log(`delivering file ${cleanentry} type ${type} binary: ${isBinary(type)}`); return { statusCode: 200, @@ -184,6 +262,7 @@ function blacklisted(path, allow, deny) { * @param {string} params.allow regular expression pattern that all delivered files must follow * @param {string} params.deny regular expression pattern that all delivered files may not follow * @param {string} params.root document root for all static files in the repository + * @param {boolean} params.esi replace relative URL references in JS and CSS with ESI references */ async function main({ owner, @@ -196,6 +275,7 @@ async function main({ allow, deny, root = '', + esi = false, }) { console.log('main()', owner, repo, ref, path, entry, strain, plain, allow, deny, root); @@ -204,12 +284,12 @@ async function main({ } if (plain) { - return deliverPlain(owner, repo, ref, entry, root); + return deliverPlain(owner, repo, ref, entry, root, esi); } return forbidden(); } module.exports = { - main, error, addHeaders, isBinary, staticBase, blacklisted, + main, error, addHeaders, isBinary, staticBase, blacklisted, getBody, }; diff --git a/test/testStatic.js b/test/testStatic.js index e35a6ec7d..24543c5b8 100644 --- a/test/testStatic.js +++ b/test/testStatic.js @@ -120,6 +120,48 @@ describe('Static Delivery Action #integrationtest', () => { }).timeout(5000); }); +describe('CSS and JS Rewriting', () => { + it('Rewrite CSS', async () => { + assert.equal(await index.getBody('text/css', '', true), ''); + assert.equal(await index.getBody('text/css', `.element { + background: url('images/../sprite.png?foo=bar'); +}`, true), `.element { + background: url('images/../sprite.png?foo=bar'); +}`); + assert.equal(await index.getBody('text/css', `.element { + background: url('https://example.com/sprite.png?foo=bar'); +}`, true), `.element { + background: url('https://example.com/sprite.png?foo=bar'); +}`); + assert.equal(await index.getBody('text/css', `.element { + background: url('images/../sprite.png'); +}`, true), `.element { + background: url(''); +}`); + assert.equal(await index.getBody('text/css', `.element { + background: url("images/../sprite.png"); +}`, true), `.element { + background: url(""); +}`); + assert.equal(await index.getBody('text/css', + '@import "fineprint.css" print;', true), + '@import "" print;'); + assert.equal(await index.getBody('text/css', + '@import \'fineprint.css\' print;', true), + '@import \'\' print;'); + assert.equal(await index.getBody('text/css', + '@import url(\'fineprint.css\') print;', true), + '@import url(\'\') print;'); + assert.equal(await index.getBody('text/css', + '@import url("fineprint.css") print;', true), + '@import url("") print;'); + }); + + it('Rewrite JS', async () => { + assert.equal(await index.getBody('text/javascript', '', true), ''); + }); +}); + describe('Static Delivery Action #unittest', () => { setupPolly({ recordFailedRequests: true,