diff --git a/api/designManager.js b/api/designManager.js index dceadc4..68c2597 100644 --- a/api/designManager.js +++ b/api/designManager.js @@ -43,9 +43,37 @@ async function fetchRawAssetByPath(accountId, path) { }); } +async function fetchModulesByPath(accountId, path) { + return http.get(accountId, { + uri: `${DESIGN_MANAGER_API_PATH}/modules/by-path/${path}?portalId=${accountId}`, + }); +} + +async function fetchPreviewModules(accountId, token) { + return http.get(accountId, { + uri: `${DESIGN_MANAGER_API_PATH}/modules/local-preview?portalId=${accountId}&previewToken=${token}`, + }); +} + +async function fetchTemplatesByPath(accountId, path) { + return http.get(accountId, { + uri: `${DESIGN_MANAGER_API_PATH}/templates/by-path/${path}?portalId=${accountId}`, + }); +} + +async function fetchPreviewTemplates(accountId, token) { + return http.get(accountId, { + uri: `${DESIGN_MANAGER_API_PATH}/templates/local-preview?portalId=${accountId}&previewToken=${token}`, + }); +} + module.exports = { fetchBuiltinMapping, fetchMenus, fetchRawAssetByPath, fetchThemes, + fetchModulesByPath, + fetchPreviewModules, + fetchTemplatesByPath, + fetchPreviewTemplates, }; diff --git a/api/domains.js b/api/domains.js new file mode 100644 index 0000000..18d7675 --- /dev/null +++ b/api/domains.js @@ -0,0 +1,16 @@ +const http = require('../http'); + +const DOMAINS_API_PATH = `/cms/v3/domains`; + +async function fetchDomains(accountId) { + const result = await http.get(accountId, { + uri: DOMAINS_API_PATH, + json: true, + }); + + return result.results; +} + +module.exports = { + fetchDomains, +}; diff --git a/api/preview.js b/api/preview.js new file mode 100644 index 0000000..cbc3d9b --- /dev/null +++ b/api/preview.js @@ -0,0 +1,16 @@ +const { request } = require('../http'); + +async function fetchPreviewRender(url, sessionInfo) { + const { sessionToken } = sessionInfo; + + const urlObject = new URL(url); + + urlObject.searchParams.append('localPreviewToken', sessionToken); + urlObject.searchParams.append('hsCacheBuster', Date.now()); + + return request(urlObject.href); +} + +module.exports = { + fetchPreviewRender, +}; diff --git a/lang/en.lyaml b/lang/en.lyaml index 9051199..d1b65eb 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -52,5 +52,3 @@ en: fieldsJsSyntaxError: "There was an error converting JS file \"{{ path }}\"" fieldsJsNotReturnArray: "There was an error loading JS file \"{{ path }}\". Expected type \"Array\" but received type \"{{ returned }}\" . Make sure that your function returns an array" fieldsJsNotFunction: "There was an error loading JS file \"{{ path }}\". Expected type \"Function\" but received type \"{{ returned }}\". Make sure that your default export is a function." - - diff --git a/lib/preview.js b/lib/preview.js new file mode 100644 index 0000000..f4b264f --- /dev/null +++ b/lib/preview.js @@ -0,0 +1,257 @@ +const http = require('http'); +const path = require('path'); +const chokidar = require('chokidar'); +const express = require('express'); +const { v4: uuidv4 } = require('uuid'); +const { logger } = require('../logger'); +const { + ApiErrorContext, + logApiErrorInstance, + logApiUploadErrorInstance, +} = require('../errorHandlers'); +const { uploadFolder } = require('./uploadFolder'); +const { shouldIgnoreFile, ignoreFile } = require('../ignoreRules'); +const { getFileMapperQueryValues } = require('../fileMapper'); +const { upload, deleteFile } = require('../api/fileMapper'); +const escapeRegExp = require('./escapeRegExp'); +const { convertToUnixPath, isAllowedExtension } = require('../path'); +const { triggerNotify } = require('./notify'); +const { getAccountConfig } = require('./config'); +const { createPreviewServerRoutes } = require('./preview/createRoutes'); +const { + getPortalDomains, + isUngatedForPreview, +} = require('./preview/previewUtils'); +const { markRemoteFsDirty } = require('./preview/routes/meta'); +const { startSprocketMenuServer } = require('./preview/sprocketMenuServer'); +const { + createHttpsRedirectingServer, +} = require('./preview/httpsRedirectingServer'); + +const fileMapperArgs = getFileMapperQueryValues({ + mode: 'publish', +}); + +async function uploadFile(accountId, src, dest) { + logger.debug(`Attempting to upload file "${src}" to "${dest}"`); + + try { + await upload(accountId, src, dest, fileMapperArgs); + logger.log(`Uploaded file ${src} to ${dest}`); + markRemoteFsDirty(); + } catch { + const uploadFailureMessage = `Uploading file ${src} to ${dest} failed`; + logger.debug(uploadFailureMessage); + logger.debug(`Retrying to upload file "${src}" to "${dest}"`); + try { + await upload(accountId, src, dest, fileMapperArgs); + markRemoteFsDirty(); + } catch (error) { + logger.error(uploadFailureMessage); + logApiUploadErrorInstance( + error, + new ApiErrorContext({ + accountId, + request: dest, + payload: src, + }) + ); + } + } +} + +async function deleteRemoteFile(accountId, remoteFilePath) { + logger.debug(`Attempting to delete file "${remoteFilePath}"`); + + try { + await deleteFile(accountId, remoteFilePath, fileMapperArgs); + logger.log(`Deleted file ${remoteFilePath}`); + markRemoteFsDirty(); + } catch (error) { + logger.error(`Deleting file ${remoteFilePath} failed`); + logger.debug(`Retrying deletion of file ${remoteFilePath}`); + try { + await deleteFile(accountId, remoteFilePath, fileMapperArgs); + markRemoteFsDirty(); + } catch (error) { + logger.error(`Deleting file ${remoteFilePath} failed`); + logApiErrorInstance( + error, + new ApiErrorContext({ + accountId, + request: remoteFilePath, + }) + ); + } + } +} + +const getDesignManagerPath = (src, dest, file) => { + const regex = new RegExp(`^${escapeRegExp(src)}`); + const relativePath = file.replace(regex, ''); + return convertToUnixPath(path.join(dest, relativePath)); +}; + +const buildDeleteFileFromPreviewBufferCallback = (sessionInfo, type) => { + const { accountId, src, dest, notify } = sessionInfo; + + return filePath => { + if (shouldIgnoreFile(filePath)) { + logger.debug(`Skipping ${filePath} due to an ignore rule`); + return; + } + + const remotePath = getDesignManagerPath(src, dest, filePath); + const deletePromise = deleteRemoteFile(accountId, remotePath); + triggerNotify(notify, 'Removed', filePath, deletePromise); + }; +}; + +const buildUploadFileToPreviewBufferCallback = (sessionInfo, notifyMessage) => { + const { accountId, src, dest, notify } = sessionInfo; + + return async filePath => { + if (!isAllowedExtension(filePath)) { + logger.debug(`Skipping ${filePath} due to unsupported extension`); + return; + } + if (shouldIgnoreFile(filePath)) { + logger.debug(`Skipping ${filePath} due to an ignore rule`); + return; + } + const destPath = getDesignManagerPath(src, dest, filePath); + const uploadPromise = uploadFile(accountId, filePath, destPath); + triggerNotify(notify, notifyMessage, filePath, uploadPromise); + }; +}; + +const initialPreviewBufferUpload = async (sessionInfo, filePaths, uploadOptions) => { + const { accountId, src, dest } = sessionInfo; + const { onFinishCallback, ...rest } = uploadOptions; + + const results = await uploadFolder(accountId, src, dest, fileMapperArgs, rest, filePaths); + onFinishCallback(results); +}; + +const startPreviewWatcher = async sessionInfo => { + const { src } = sessionInfo; + let watcherIsReady = false; + + const watcher = chokidar.watch(src, { + ignoreInitial: true, // makes initial addition of files not trigger the watcher + ignored: file => shouldIgnoreFile(file), + }); + + const addFileCallback = buildUploadFileToPreviewBufferCallback( + sessionInfo, + 'Added' + ); + const changeFileCallback = buildUploadFileToPreviewBufferCallback( + sessionInfo, + 'Change' + ); + const deleteFileCallback = buildDeleteFileFromPreviewBufferCallback( + sessionInfo, + 'file' + ); + const deleteFolderCallback = buildDeleteFileFromPreviewBufferCallback( + sessionInfo, + 'folder' + ); + + watcher.on('ready', () => { + watcherIsReady = true; + }); + watcher.on('add', addFileCallback); + watcher.on('change', changeFileCallback); + watcher.on('error', error => + logger.error(`An error occurred while watching files: ${error}`) + ); + + watcher.on('unlink', deleteFileCallback); + watcher.on('unlinkDir', deleteFolderCallback); + + function sleep(ms) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); + } + while (!watcherIsReady) { + await sleep(1); + // do nothing... surely there's a better way to do this... + } + return watcher; +}; + +const createLocalHttpServer = async sessionInfo => { + const expressServer = express(); + expressServer.use('/', await createPreviewServerRoutes(sessionInfo)); + + return expressServer; +}; + +const preview = async ( + accountId, + src, + dest, + { notify, filePaths, skipUpload, noSsl, port, uploadOptions } +) => { + const accountConfig = getAccountConfig(accountId); + const domains = await getPortalDomains(accountId); + const sessionToken = uuidv4(); + const PORT = port || 3000; + const protocol = noSsl ? 'http' : 'https'; + + const sessionInfo = { + src, + dest: `@preview/${sessionToken}/${dest}`, + fakeDest: dest, + portalName: accountConfig.name, + accountId, + env: accountConfig.env, + personalAccessKey: accountConfig.personalAccessKey, + // we find hublet later in the content metadata fetch + // can we get that ahead of time? hardcoding it for now + hublet: 'na1', + sessionToken, + domains, + PORT, + protocol, + }; + const ungated = await isUngatedForPreview(sessionInfo); + if (!ungated) { + logger.log( + `Portal ${accountId} is missing a required gate for this feature.` + ); + process.exit(); + } + if (notify) { + ignoreFile(notify); + } + + if (!skipUpload) { + await initialPreviewBufferUpload(sessionInfo, filePaths, uploadOptions); + } + const expressServer = await createLocalHttpServer(sessionInfo); + const previewWatcher = await startPreviewWatcher(sessionInfo); + + if (!noSsl) { + const { + server, + innerHTTPServer, + innerHTTPSServer, + } = await createHttpsRedirectingServer(expressServer, domains); + server.listen(PORT); + } else { + const httpServer = http.createServer(expressServer); + httpServer.listen(PORT); + } + startSprocketMenuServer(sessionInfo); + logger.log( + `Local dev server started at ${protocol}://hslocal.net:${PORT} for portal ${accountId}` + ); +}; + +module.exports = { + preview, +}; diff --git a/lib/preview/createRoutes.js b/lib/preview/createRoutes.js new file mode 100644 index 0000000..ba743c7 --- /dev/null +++ b/lib/preview/createRoutes.js @@ -0,0 +1,38 @@ +const { Router } = require('express'); +const { logger } = require('./../../logger'); + +const { buildIndexRouteHandler } = require('./routes/index.js'); +const { buildModuleRouteHandler } = require('./routes/module.js'); +const { buildTemplateRouteHandler } = require('./routes/template.js'); +const { buildMetaRouteHandler } = require('./routes/meta.js'); + +const { buildProxyRouteHandler } = require('./routes/proxyPathPageRouteHandler.js'); +const { buildProxyPageRouteHandler } = require('./routes/proxyPageRouteHandler.js'); +const { proxyPathPageResourceRedirect } = require('./routes/proxyPathPageResourceRedirect.js'); +const { proxyPageResourceRedirect } = require('./routes/proxyPageResourceRedirect.js'); + +const createPreviewServerRoutes = async (sessionInfo) => { + const previewServerRouter = Router(); + previewServerRouter.get('/proxy', buildProxyRouteHandler(sessionInfo)); + previewServerRouter.get('/module/:modulePath(*)', buildModuleRouteHandler(sessionInfo)); + previewServerRouter.get('/template/:templatePath(*)', buildTemplateRouteHandler(sessionInfo)); + // fetches server metadata from the client (used by refresh script to check if fs has been changed) + previewServerRouter.get('/meta', buildMetaRouteHandler(sessionInfo)); + // handles resources on the proxied page, so a fetch from relative path gets proxied too + previewServerRouter.get('/*', proxyPathPageResourceRedirect) + previewServerRouter.get('/*', proxyPageResourceRedirect); + previewServerRouter.post('/*', proxyPageResourceRedirect); + previewServerRouter.delete('/*', proxyPageResourceRedirect); + previewServerRouter.head('/*', proxyPageResourceRedirect); + previewServerRouter.put('/*', proxyPageResourceRedirect); + previewServerRouter.options('/*', proxyPageResourceRedirect); + previewServerRouter.get('/*', buildProxyPageRouteHandler(sessionInfo)); + // index route + previewServerRouter.get('/', buildIndexRouteHandler(sessionInfo)); + + return previewServerRouter; +} + +module.exports = { + createPreviewServerRoutes, +} diff --git a/lib/preview/httpsRedirectingServer.js b/lib/preview/httpsRedirectingServer.js new file mode 100644 index 0000000..604530c --- /dev/null +++ b/lib/preview/httpsRedirectingServer.js @@ -0,0 +1,86 @@ +const http = require('http'); +const https = require('https'); +const net = require('net'); +const os = require('os') +const { unlinkSync } = require('fs'); +const { silenceConsoleWhile } = require('./previewUtils'); + +const createCert = async (domainsToProxy) => { + const additionalMkcertHosts = domainsToProxy + .map(proxyDomain => [ + `${proxyDomain.domain}.localhost`, + `${proxyDomain.domain}.hslocal.net` + ]) + .flat(); + const hosts = ['localhost', 'hslocal.net', ...additionalMkcertHosts]; + const { createCertificate } = await import('mkcert-cli'); + const { key, cert } = await silenceConsoleWhile(createCertificate, { + keyFilePath: `${os.tmpdir()}/hstmp/hsLocalSshKey.pem`, + certFilePath: `${os.tmpdir()}/hstmp/hsLocalSshCert.pem` + }, hosts); + unlinkSync(`${os.tmpdir()}/hstmp/hsLocalSshKey.pem`); + unlinkSync(`${os.tmpdir()}/hstmp/hsLocalSshCert.pem`); + return { key, cert }; +} + +const requireHTTPS = (message, response) => { + const newLocation = `https://${message.headers.host}${message.url}`; + + response + .writeHead(302, { + Location: newLocation, + }) + .end(); +} + +// Running http and https servers on the same port pulled from https://stackoverflow.com/a/42019773 +const createHttpsRedirectingServer = async (handler, domainsToProxy) => { + + const { key, cert } = await createCert(domainsToProxy) + + const innerHTTPServer = http.createServer(requireHTTPS); + const innerHTTPSServer = https.createServer({ key, cert }, handler); + + const server = net.createServer(socket => { + socket.once('data', buffer => { + // Pause the socket + socket.pause(); + + // Determine if this is an HTTP(s) request + const byte = buffer[0]; + + let protocol; + if (byte === 22) { + protocol = 'https'; + } else if (32 < byte && byte < 127) { + protocol = 'http'; + } else { + throw new Error( + 'Unknown issue with incoming data, unknown if http or https' + ); + } + + const proxy = protocol === 'http' ? innerHTTPServer : innerHTTPSServer; + if (proxy) { + // Push the buffer back onto the front of the data stream + socket.unshift(buffer); + + // Emit the socket to the HTTP(s) server + proxy.emit('connection', socket); + } + + // As of NodeJS 10.x the socket must be + // resumed asynchronously or the socket + // connection hangs, potentially crashing + // the process. Prior to NodeJS 10.x + // the socket may be resumed synchronously. + process.nextTick(() => socket.resume()); + }); + }); + + return { server, innerHTTPServer, innerHTTPSServer }; +} + +module.exports = { + createHttpsRedirectingServer +} diff --git a/lib/preview/previewUtils.js b/lib/preview/previewUtils.js new file mode 100644 index 0000000..a862236 --- /dev/null +++ b/lib/preview/previewUtils.js @@ -0,0 +1,185 @@ +const { fetchDomains } = require('../../api/domains'); +const { getAccountId, isTrackingAllowed, getAccountConfig } = require('../config'); +const { platform, release } = require('os'); +const { trackUsage } = require('../../api/fileMapper'); +const { enabledFeaturesForPersonalAccessKey } = require('../../personalAccessKey'); +const { stringify } = require('querystring'); +const { logger } = require('./../../logger'); + +const VALID_PROXY_DOMAIN_SUFFIXES = ['localhost', 'hslocal.net']; + +const HS_PREVIEW_GATE = "cms:localHublPreviews"; + +const getPortalDomains = async (accountId) => { + try { + const result = await fetchDomains(accountId); + return result; + } catch (error) { + return []; + } +} + +const getPreviewUrl = (sessionInfo, queryParams) => { + const { accountId, env, hublet } = sessionInfo; + + return `http://${accountId}.hubspotpreview${ + env === 'qa' ? 'qa' : '' + }-${hublet}.com/_hcms/preview/template/multi?${stringify(queryParams)}`; +} + +const insertAtEndOfBody = (html, script) => { + const insertAt = (baseStr, index, insertStr) => { + return `${baseStr.slice(0, index)}${insertStr}${baseStr.slice(index)}`; + } + const endOfBodyIndex = html.lastIndexOf("
+ ${content} + +No domains found. You either don't have any domains set up in your portal or your personal access key is missing a scope 'cms.domains.read' required for this feature.
" + } +No modules found in ${fakeDest}
` } +No templates found in ${fakeDest}
` } +"); + return insertAt(html, endOfBodyIndex, script); +} + +const addRefreshScript = (html) => { + const refreshScript = ` + + `; + return insertAtEndOfBody(html, refreshScript); +} + +const getSubDomainFromValidLocalDomain = hostname => { + for (const validProxyDomainSuffix of VALID_PROXY_DOMAIN_SUFFIXES) { + if (hostname.endsWith(`.${validProxyDomainSuffix}`)) { + return hostname.slice(0, -1 * validProxyDomainSuffix.length - 1); + } + } +}; + +const internalRoutes = { + HCMS: '/_hcms/', + HS_FS: '/hs-fs/', + HUB_FS: '/hubfs/' +} + +const isInternalCMSRoute = (req) => + Object.values(internalRoutes).some((route => req.path.startsWith(route))); + +const silenceConsoleWhile = async (act, ...args) => { + const tmpConsole = console; + console = { log: () => {} } // ! + const result = await act(...args); + console = tmpConsole; + return result; +} + +const memoize = (func, cacheBustCallback) => { + const cache = {}; + return async (...args) => { + const stringArgs = args.toString(); + const storedResult = cache[stringArgs]; + if (storedResult && (cacheBustCallback ? !cacheBustCallback() : true)) { + //console.log(`Cache hit ${func}`) + return storedResult; + } + //console.log(`Cache miss ${func}`) + const res = await func(...args); + cache[stringArgs] = res; + return res; + } +} + +const hidePreviewInDest = (previewDest) => previewDest.split('/').slice(2).join('/'); + +const trackPreviewEvent = async (action) => { + if (!isTrackingAllowed()) { + return; + } + const accountId = getAccountId(); + + trackUsage( + 'cli-interaction', + 'INTERACTION', + { + applicationName: 'hubspot.preview', + os: `${platform()} ${release()}`, + authType: getAuthType(accountId), + action, + }, + accountId + ).catch( + (err) => { + logger.debug(`trackUsage failed: ${JSON.stringify(err, null, 2)}`); + } + ); +} + +const isUngatedForPreview = async (sessionInfo) => { + const { accountId } = sessionInfo; + + const enabledFeatures = await enabledFeaturesForPersonalAccessKey(accountId); + + return (Object.keys(enabledFeatures).includes(HS_PREVIEW_GATE) + && enabledFeatures[HS_PREVIEW_GATE] === true) +} + +const buildHTMLResponse = (content) => { + return ` + +
+ +
+ `; + } + +const getAuthType = accountId => { + let authType = 'unknown'; + + if (accountId) { + const accountConfig = getAccountConfig(accountId); + if (accountConfig && accountConfig.authType) { + authType = accountConfig.authType; + } + } + + return authType; +}; + +module.exports = { + isInternalCMSRoute, + VALID_PROXY_DOMAIN_SUFFIXES, + getSubDomainFromValidLocalDomain, + getPortalDomains, + getPreviewUrl, + addRefreshScript, + silenceConsoleWhile, + memoize, + hidePreviewInDest, + trackPreviewEvent, + isUngatedForPreview, + buildHTMLResponse, +} diff --git a/lib/preview/proxyPage.js b/lib/preview/proxyPage.js new file mode 100644 index 0000000..d2c6c28 --- /dev/null +++ b/lib/preview/proxyPage.js @@ -0,0 +1,27 @@ +const { addRefreshScript } = require('./previewUtils'); +const { fetchPreviewRender } = require('../../api/preview'); + +const proxyPage = async ( + res, + urlToProxy, + sessionInfo +) => { + try { + const pageHtml = await fetchPreviewRender(urlToProxy, sessionInfo); + + const embeddedHtml = addRefreshScript(pageHtml); + res.status(200).set({ 'Content-Type': 'text/html' }).end(embeddedHtml); + } catch (error) { + const { accountId } = sessionInfo; + res + .status(500) + .end( + `Failed proxy render of page ${urlToProxy} hub id = ${accountId}\n\n${error.message}` + ); + return; + } +} + +module.exports = { + proxyPage +} diff --git a/lib/preview/routes/index.js b/lib/preview/routes/index.js new file mode 100644 index 0000000..ade92c0 --- /dev/null +++ b/lib/preview/routes/index.js @@ -0,0 +1,133 @@ +const { + hidePreviewInDest, + trackPreviewEvent, +} = require('../previewUtils'); +const { + fetchPreviewTemplates, + fetchPreviewModules +} = require('../../../api/designManager'); +const { parse: pathParse } = require('path'); +const { logger } = require('./../../../logger'); +const { isCodedFile } = require('./../../../templates'); + +const buildIndexRouteHandler = (sessionInfo) => { + return async (req, res) => { + trackPreviewEvent('view-index-route'); + + const responseHTML = await buildIndexHtml(sessionInfo); + res.status(200).set({ 'Content-Type': 'text/html' }).end(responseHTML); + } +} + +const buildIndexHtml = async (sessionInfo) => { + const { domains, dest, PORT } = sessionInfo; + const fakeDest = hidePreviewInDest(dest); + const modulesHtml = await getModulesForDisplayToUser(sessionInfo); + const templatesHtml = await getTemplatesForDisplayToUser(sessionInfo); + return ` + + +
+ +
+