diff --git a/lib/MakeswiftPreview.js b/lib/MakeswiftPreview.js index b4545205..45231b90 100644 --- a/lib/MakeswiftPreview.js +++ b/lib/MakeswiftPreview.js @@ -4,6 +4,7 @@ import tunnelManagerModule from './TunnelManager.js'; import previewSessionClientModule from './PreviewSessionClient.js'; import PreviewSessionHeartbeat from './PreviewSessionHeartbeat.js'; import { createPreviewProxy } from './preview-proxy.js'; +import { createStorefrontLinkRewriteRule } from './storefront-link-rewrite.js'; const PREVIEW_LISTEN_HOST = 'localhost'; // Ctrl-C must not be slow. A session that is not revoked expires with its TTL. @@ -147,6 +148,7 @@ class MakeswiftPreview { logger: this._logger, }), ], + rewriteRules: [createStorefrontLinkRewriteRule(this._session.storeUrl)], tunnel: false, }; } diff --git a/lib/MakeswiftPreview.spec.js b/lib/MakeswiftPreview.spec.js index 0f2a9e9a..972d0896 100644 --- a/lib/MakeswiftPreview.spec.js +++ b/lib/MakeswiftPreview.spec.js @@ -168,12 +168,21 @@ describe('MakeswiftPreview', () => { describe('getBrowserSyncOptions', () => { it('should serve a signing proxy to the storefront on loopback only', async () => { const { preview, previewProxyFactory, logger } = await startPreview(); - expect(preview.getBrowserSyncOptions(localPort)).toEqual({ + const { rewriteRules, ...options } = preview.getBrowserSyncOptions(localPort); + expect(options).toEqual({ listen: 'localhost', server: { baseDir: [] }, middleware: [previewProxyMiddleware], tunnel: false, }); + // The rule itself is tested in storefront-link-rewrite.spec.js. + expect(rewriteRules).toHaveLength(1); + const [{ match, fn }] = rewriteRules; + const html = ''; + const rewritten = html.replace(match, (...args) => + fn({ headers: { host: 'localhost:3000' } }, {}, ...args), + ); + expect(rewritten).toBe(''); expect(previewProxyFactory).toHaveBeenCalledWith({ previewSession: preview.session, localPort, diff --git a/lib/storefront-link-rewrite.js b/lib/storefront-link-rewrite.js new file mode 100644 index 00000000..2f20a90b --- /dev/null +++ b/lib/storefront-link-rewrite.js @@ -0,0 +1,46 @@ +import { escapeRegExp } from 'lodash-es'; + +// The link must start here. This stops a match on the "//host" part of "ftp://host" or +// "https://other.test//host". +const LINK_START = String.raw`(? { + const localSlashes = slashes.includes('\\') ? '\\/\\/' : '//'; + return `http:${localSlashes}${req.headers.host}`; + }, + }; +} + +export { buildStorefrontLinkPattern, createStorefrontLinkRewriteRule }; diff --git a/lib/storefront-link-rewrite.spec.js b/lib/storefront-link-rewrite.spec.js new file mode 100644 index 00000000..a3e86ffb --- /dev/null +++ b/lib/storefront-link-rewrite.spec.js @@ -0,0 +1,163 @@ +import { + buildStorefrontLinkPattern, + createStorefrontLinkRewriteRule, +} from './storefront-link-rewrite.js'; + +const LOCAL_HOST = 'localhost:3000'; + +// Applies the rule the same way BrowserSync (resp-modifier) does. +const rewrite = (html, storeUrl = 'https://www.example.com') => { + const rule = createStorefrontLinkRewriteRule(storeUrl); + return html.replace(rule.match, (...args) => + rule.fn({ headers: { host: LOCAL_HOST } }, {}, ...args), + ); +}; + +describe('createStorefrontLinkRewriteRule', () => { + describe('rewrites links to the storefront host', () => { + it.each([ + ['https link', 'https://www.example.com/products', 'http://localhost:3000/products'], + ['http link', 'http://www.example.com/products', 'http://localhost:3000/products'], + [ + 'protocol-relative link', + '//www.example.com/products', + 'http://localhost:3000/products', + ], + [ + 'uppercase link', + 'HTTPS://WWW.EXAMPLE.COM/products', + 'http://localhost:3000/products', + ], + ['host with no path', 'https://www.example.com', 'http://localhost:3000'], + ['host then query', 'https://www.example.com?a=1', 'http://localhost:3000?a=1'], + ['host then hash', 'https://www.example.com#top', 'http://localhost:3000#top'], + ['host in double quotes', '"https://www.example.com"', '"http://localhost:3000"'], + ['host in single quotes', "'https://www.example.com'", "'http://localhost:3000'"], + ['host then <', 'https://www.example.com', 'http://localhost:3000'], + [ + 'host at end of sentence', + 'Visit https://www.example.com.', + 'Visit http://localhost:3000.', + ], + [ + 'JSON-escaped link', + String.raw`"https:\/\/www.example.com\/products"`, + String.raw`"http:\/\/localhost:3000\/products"`, + ], + [ + 'JSON-escaped protocol-relative link', + String.raw`"\/\/www.example.com\/products"`, + String.raw`"http:\/\/localhost:3000\/products"`, + ], + [ + 'link in a query param of another link', + 'https://other.test/?r=https://www.example.com/x', + 'https://other.test/?r=http://localhost:3000/x', + ], + ])('%s', (_name, input, expected) => { + expect(rewrite(input)).toBe(expected); + }); + + it('rewrites all links in a document', () => { + const html = [ + 'A', + '', + String.raw``, + ].join('\n'); + + expect(rewrite(html)).toBe( + [ + 'A', + '', + String.raw``, + ].join('\n'), + ); + }); + + it('gives the same result when the rule is used more than once', () => { + // A global RegExp keeps lastIndex; String.prototype.replace must reset it. + const rule = createStorefrontLinkRewriteRule('https://www.example.com'); + const apply = (html) => + html.replace(rule.match, (...args) => + rule.fn({ headers: { host: LOCAL_HOST } }, {}, ...args), + ); + + expect(apply('https://www.example.com/a')).toBe('http://localhost:3000/a'); + expect(apply('https://www.example.com/a')).toBe('http://localhost:3000/a'); + }); + + it('uses the Host header of the request', () => { + const rule = createStorefrontLinkRewriteRule('https://www.example.com'); + const result = 'https://www.example.com/a'.replace(rule.match, (...args) => + rule.fn({ headers: { host: '127.0.0.1:4000' } }, {}, ...args), + ); + + expect(result).toBe('http://127.0.0.1:4000/a'); + }); + }); + + describe('does not change links to other hosts', () => { + it.each([ + ['longer host (subdomain suffix)', 'https://www.example.com.evil.test/'], + ['longer host (hyphen)', 'https://www.example.com-evil.test/'], + ['longer host (more letters)', 'https://www.example.community/'], + ['subdomain of the host', 'https://shop.www.example.com/'], + ['parent domain', 'https://example.com/'], + ['same host, other port', 'https://www.example.com:8443/'], + ['host with trailing dot (FQDN)', 'https://www.example.com./x'], + ['host with user info', 'https://user@www.example.com/'], + ['host with no slashes', 'www.example.com/products'], + ['host as a path segment', '/www.example.com/products'], + ['other scheme', 'ftp://www.example.com/'], + ['host after // in a path', 'https://other.test//www.example.com/'], + ['dot in host is not a wildcard', 'https://wwwXexampleYcom/'], + ])('%s', (_name, input) => { + expect(rewrite(input)).toBe(input); + }); + }); + + describe('known limits (not rewritten)', () => { + it.each([ + ['URL-encoded link', 'https%3A%2F%2Fwww.example.com%2Fx'], + ['double JSON-escaped link', String.raw`https:\\\/\\\/www.example.com`], + ])('%s', (_name, input) => { + expect(rewrite(input)).toBe(input); + }); + + it('does not rewrite the bare domain when the store URL has www', () => { + expect(rewrite('https://example.com/x')).toBe('https://example.com/x'); + }); + }); + + describe('store URL with a port', () => { + const storeUrl = 'https://store.test:8443'; + + it('rewrites links with the same port', () => { + expect(rewrite('https://store.test:8443/x', storeUrl)).toBe('http://localhost:3000/x'); + }); + + it.each([ + ['no port', 'https://store.test/x'], + ['other port', 'https://store.test:9443/x'], + ['longer port', 'https://store.test:84430/x'], + ])('does not rewrite links with %s', (_name, input) => { + expect(rewrite(input, storeUrl)).toBe(input); + }); + }); + + it('ignores a default port in the store URL', () => { + // `new URL('https://host:443').host` is `host`. + expect(rewrite('https://www.example.com/x', 'https://www.example.com:443')).toBe( + 'http://localhost:3000/x', + ); + }); +}); + +describe('buildStorefrontLinkPattern', () => { + it('escapes regex characters in the host', () => { + const pattern = buildStorefrontLinkPattern('a.b'); + + expect('https://a.b/'.match(pattern)).toEqual(['https://a.b']); + expect('https://aXb/'.match(pattern)).toBeNull(); + }); +});