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("</body>"); + return insertAt(html, endOfBodyIndex, script); +} + +const addRefreshScript = (html) => { + const refreshScript = ` + <script> + (() => { + const MAX_WAIT = 16000; + const NORMAL_WAIT_MS = 1000; + const BACKOFF_RATIO = 2; + let nextWait = NORMAL_WAIT_MS; + let timeWaited = 0; + + setInterval(() => { + timeWaited += NORMAL_WAIT_MS; + if (timeWaited !== nextWait) return; + timeWaited = 0; + const res = fetch(window.location.origin + '/meta') + .then(async (res) => { + const hsServerState = await res.json(); + if (hsServerState["REMOTE_FS_IS_DIRTY"]) { + location.reload(); + } + nextWait = NORMAL_WAIT_MS; + }) + .catch(err => { + if (nextWait * BACKOFF_RATIO <= MAX_WAIT) { + nextWait *= BACKOFF_RATIO; + } + console.log('Disconnected from local server... (retrying in ' + nextWait / 1000 + 's)'); + }); + }, NORMAL_WAIT_MS); + })(); + </script> + `; + 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 ` + <!DOCTYPE html> + <head> + </head> + <body> + ${content} + </body> + `; + } + +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 ` + <!DOCTYPE html> + <html style="height:100%"> + <head> + </head> + <body style="height:100%;display:flex;flex-direction:column;align-items:center;"> + <div> + <h1>Domains</h1> + ${ domains.length ? + getSiteList(domains, PORT) : + "<p>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.</p>" + } + </div> + <div style="width:100%;display:flex;flex-direction:row;justify-content:space-evenly"> + <div> + <h1>Modules</h1> + ${ modulesHtml ? modulesHtml : `<p>No modules found in ${fakeDest}</p>` } + </div> + <div> + <h1>Templates</h1> + ${ templatesHtml ? templatesHtml : `<p>No templates found in ${fakeDest}</p>` } + </div> + </div> + </body> + </html> + `; +} + +const getSiteList = (domains, PORT) => { + return listify( + domains, + domainObj => `http://${domainObj.domain}.hslocal.net:${PORT}`, + domainObj => domainObj.domain + ); +} + +const listify = (objects, hrefBuilder, labelBuilder) => { + return '<ul>' + + objects.reduce((x,y) => x += `<li><a href="${hrefBuilder(y)}">${labelBuilder(y)}</a></li>`, '') + + '</ul>'; +} + +const getModulesForDisplayToUser = async (sessionInfo) => { + const { accountId, sessionToken } = sessionInfo; + try { + const res = await fetchPreviewModules(accountId, sessionToken); + const modulePaths = res + .objects + .map(moduleObj => moduleObj.path); + const filesGroupedByFolder = groupByFolder(modulePaths) + const htmlToRender = renderFilesGroupedByFolder(filesGroupedByFolder, 'module') + return htmlToRender; + } catch (err) { + logger.error(`Failed to fetch modules for index page: ${err}`); + return undefined; + } +} + +const getTemplatesForDisplayToUser = async (sessionInfo) => { + const { accountId, sessionToken } = sessionInfo; + try { + const res = await fetchPreviewTemplates(accountId, sessionToken); + const templatePaths = res + .objects + .filter(templateObj => isCodedFile(templateObj.filename)) + .map(templateObj => templateObj.path); + const filesGroupedByFolder = groupByFolder(templatePaths) + const htmlToRender = renderFilesGroupedByFolder(filesGroupedByFolder, 'template') + return htmlToRender; + } catch (err) { + logger.error(`Failed to fetch templates for index page: ${err}`); + } +} + +const groupByFolder = (paths) => { + return paths.reduce((acc, cur) => { + const { dir, base } = pathParse(cur); + if (Object.keys(acc).includes(dir)) { + acc[dir].push(base); + } else { + acc[dir] = [base]; + } + return acc; + }, {}) +} + +const renderFilesGroupedByFolder = (filesGroupedByFolder, endpoint) => { + const folders = Object.keys(filesGroupedByFolder); + return folders.reduce((outer_acc, folder) => { + const fakeDest = hidePreviewInDest(folder); + const folderDisplay = '<b>' + + fakeDest + + '</b>' + + listify( + filesGroupedByFolder[folder], + x => `/${endpoint}/${fakeDest}/${x}`, + x => x + ); + outer_acc += folderDisplay; + return outer_acc; + }, ''); +} + + +module.exports = { + buildIndexRouteHandler +} diff --git a/lib/preview/routes/meta.js b/lib/preview/routes/meta.js new file mode 100644 index 0000000..75a026b --- /dev/null +++ b/lib/preview/routes/meta.js @@ -0,0 +1,19 @@ +const hsServerState = { + REMOTE_FS_IS_DIRTY: false +} + +const buildMetaRouteHandler = (sessionInfo) => { + return async (req, res) => { + res.json(hsServerState); + hsServerState["REMOTE_FS_IS_DIRTY"] = false; + } +} + +const markRemoteFsDirty = () => { + hsServerState["REMOTE_FS_IS_DIRTY"] = true; +} + +module.exports = { + buildMetaRouteHandler, + markRemoteFsDirty, +} diff --git a/lib/preview/routes/module.js b/lib/preview/routes/module.js new file mode 100644 index 0000000..292774e --- /dev/null +++ b/lib/preview/routes/module.js @@ -0,0 +1,70 @@ +const http = require('../../../http') +const { fetchModulesByPath } = require('../../../api/designManager'); +const { + addRefreshScript, + getPreviewUrl, + trackPreviewEvent, + buildHTMLResponse +} = require('../previewUtils'); +const { logger } = require('./../../../logger'); + +const buildModuleRouteHandler = (sessionInfo) => { + const { accountId, sessionToken } = sessionInfo; + + return async (req, res) => { + trackPreviewEvent('view-module-route'); + + const { modulePath } = req.params; + + if (!modulePath) { + res.status(200).set({ 'Content-Type': 'text/html' }).end(buildModuleIndex()); + return; + } + + const calculatedPath = `@preview/${sessionToken}/${modulePath}.module`; + let customWidgetInfo; + try { + customWidgetInfo = await fetchModulesByPath(accountId, calculatedPath); + } catch (err) { + logger.error(`Failed to fetch module preview for ${calculatedPath}`) + } + if (!customWidgetInfo || !('moduleId' in customWidgetInfo && 'previewKey' in customWidgetInfo)) { + res.status(200).set({ 'Content-Type': 'text/html' }).end(buildErrorIndex()); + return; + } + const params = { + module_id: customWidgetInfo.moduleId, + hs_preview_key: customWidgetInfo.previewKey, + updated: Date.now(), + ...req.query + } + const previewUrl = new URL(getPreviewUrl(sessionInfo, params)) ; + const result = await http.get(accountId, { + baseUrl: previewUrl.origin, + uri: previewUrl.pathname, + query: params, + json: false, + useQuerystring: true, + }); + const html = addRefreshScript(result) + res.status(200).set({ 'Content-Type': 'text/html' }).end(html); + } +} + +const buildErrorIndex = () => buildHTMLResponse(` + <div> + <h2>Error</h2> + <p>Failed to fetch module data.</p> + </div> +`); + +const buildModuleIndex = () => buildHTMLResponse(` + <div> + <h2>Module</h2> + <p>Please provide a module path in the request</p> + </div> +`); + +module.exports = { + buildModuleRouteHandler +} diff --git a/lib/preview/routes/proxyPageResourceRedirect.js b/lib/preview/routes/proxyPageResourceRedirect.js new file mode 100644 index 0000000..143651d --- /dev/null +++ b/lib/preview/routes/proxyPageResourceRedirect.js @@ -0,0 +1,59 @@ +const request = require('request'); +const { + getSubDomainFromValidLocalDomain, + VALID_PROXY_DOMAIN_SUFFIXES, + isInternalCMSRoute +} = require('../previewUtils'); + +const proxyPageResourceRedirect = (req, res, next) => { + const isAHtmlRequest = req.accepts().includes('text/html'); + + if ( + (isAHtmlRequest && !isInternalCMSRoute(req)) || + VALID_PROXY_DOMAIN_SUFFIXES.includes(req.hostname) + ) { + next(); + return; + } + + const domainToProxy = getSubDomainFromValidLocalDomain(req.hostname); + const pathToProxy = req.originalUrl; + const urlToProxy = `https://${domainToProxy}${pathToProxy}`; + + if (req.method === 'GET') { + request(urlToProxy).pipe(res); + } else { + const contentType = req.headers['content-type']; + const isSendingJSON = + contentType && /\bjson\b/.test(req.headers['content-type'].toString()); + + const headerEntries = Object.entries(req.headers).filter(([header]) => + // Need to reset these headers for https (and recompression/encoding?) to work properly + !['host', 'origin', 'connection', 'content-length'].includes( + header.toLowerCase() + ) && + !header.startsWith('sec-') + ); + + let body; + + if (req.body) { + if (isSendingJSON) { + body = JSON.stringify(req.body); + } else { + body = req.body.toString(); + } + } + + request({ + url: urlToProxy, + method: req.method, + headers: Object.fromEntries(headerEntries), + body, + }).pipe(res); + } +} + +module.exports = { + proxyPageResourceRedirect +} diff --git a/lib/preview/routes/proxyPageRouteHandler.js b/lib/preview/routes/proxyPageRouteHandler.js new file mode 100644 index 0000000..7873645 --- /dev/null +++ b/lib/preview/routes/proxyPageRouteHandler.js @@ -0,0 +1,43 @@ +const { proxyPage } = require('../proxyPage.js'); +const { + getSubDomainFromValidLocalDomain, + VALID_PROXY_DOMAIN_SUFFIXES, + trackPreviewEvent +} = require('../previewUtils.js'); + +const buildProxyPageRouteHandler = (sessionInfo) => { + return async (request, response, next) => { + if (!request.accepts().includes('text/html')) { + return response.sendStatus(404); + } + + const domainToProxy = getSubDomainFromValidLocalDomain(request.hostname); + + if (!domainToProxy) { + if (VALID_PROXY_DOMAIN_SUFFIXES.includes(request.hostname)) { + next(); + return; + } else { + const message = `Can't make a proxy request, the domain '${request.hostname}' is not a valid proxy-able domain`; + console.warn(message); + return response.status(400).send(message); + } + } + trackPreviewEvent('proxy-page-route') + const urlToProxy = `http://${domainToProxy}${request.originalUrl}`; + + try { + await proxyPage( + response, + urlToProxy, + sessionInfo, + ); + } catch (e) { + next(e); + } + } +} + +module.exports = { + buildProxyPageRouteHandler, +} diff --git a/lib/preview/routes/proxyPathPageResourceRedirect.js b/lib/preview/routes/proxyPathPageResourceRedirect.js new file mode 100644 index 0000000..b1a6d13 --- /dev/null +++ b/lib/preview/routes/proxyPathPageResourceRedirect.js @@ -0,0 +1,41 @@ +const { isInternalCMSRoute } = require('../previewUtils'); +const request = require('request'); + +const proxyPathPageResourceRedirect = (req, res, next) => { + const isAHtmlRequest = req.accepts().includes('text/html'); + + // proxy when referer's path is /proxy and has page query param + let proxyPageUrl; + let refererUrl; + try { + refererUrl = new URL(req.headers.referer); + proxyPageUrl = new URL( + refererUrl.searchParams.get('page') + ); + } catch (e) { + next(); + return; + } + if (!refererUrl.pathname.startsWith('/proxy')) { + next(); + return; + } + + // handle anchor links unless internal CMS route + if (isAHtmlRequest && !isInternalCMSRoute(req)) { + const encodedPageUrl = encodeURIComponent( + `${proxyPageUrl.origin}${req.url}` + ); + res.redirect(`/proxy?page=${encodedPageUrl}`); + return; + } + + const urlToProxy = `https://${proxyPageUrl.host}${req.url}`; + request(urlToProxy) + .then(response => response.body.pipe(res)) + .catch(err => console.error(err)); +} + +module.exports = { + proxyPathPageResourceRedirect, +} diff --git a/lib/preview/routes/proxyPathPageRouteHandler.js.js b/lib/preview/routes/proxyPathPageRouteHandler.js.js new file mode 100644 index 0000000..efad899 --- /dev/null +++ b/lib/preview/routes/proxyPathPageRouteHandler.js.js @@ -0,0 +1,63 @@ +const { proxyPage } = require('../proxyPage'); +const { trackPreviewEvent, buildHTMLResponse } = require('../previewUtils'); +const { logger } = require('./../../../logger'); + +const buildProxyRouteHandler = (sessionInfo) => { + + return async (req, res) => { + if (!req.query.page) { + res.status(200).set({ 'Content-Type': 'text/html' }).end(buildProxyIndex()); + return; + } + + // parse proxy page URL from query param + let proxyPageUrl; + try { + proxyPageUrl = new URL(req.query.page); + } catch (e) { + const message = + 'Please provide a valid page query parameter, e.g., http://localhost:3000/proxy?page=https://yourdomain.com/path'; + logger.warn(message); + return res.status(400).send(message); + } + // validate proxy page URL query params + if (proxyPageUrl.searchParams.has('hs_preview')) { + const message = `Can't make a proxy request, you cannot proxy URLs that include internal query params like hs_preview`; + logger.warn(message); + return res.status(400).send(message); + } + trackPreviewEvent('proxy-path-route'); + try { + await proxyPage( + res, + proxyPageUrl.href, + sessionInfo + ); + } catch (e) { + const message = `Failed to fetch proxy page for ${proxyPageUrl.href}`; + return res.status(400).send(message); + } + } +} + +const buildProxyIndex = () => buildHTMLResponse(` + <div> + <h2>Local Proxy</h2> + <form action="/proxy"> + <label htmlFor="page" style={{ display: 'block' }}> + Page URL + </label> + <input + style={{ width: '300px' }} + name="page" + type="text" + placeholder="https://somecmspage.com/path" + /> + <button style={{ display: 'block' }}>Proxy</button> + </form> + </div> +`); + +module.exports = { + buildProxyRouteHandler +} diff --git a/lib/preview/routes/template.js b/lib/preview/routes/template.js new file mode 100644 index 0000000..2241ffa --- /dev/null +++ b/lib/preview/routes/template.js @@ -0,0 +1,62 @@ +const http = require('../../../http'); +const { fetchTemplatesByPath } = require('../../../api/designManager'); +const { getPreviewUrl, addRefreshScript, buildHTMLResponse } = require('../previewUtils'); +const { logger } = require('./../../../logger'); + +const buildTemplateRouteHandler = (sessionInfo) => { + const { accountId, sessionToken } = sessionInfo; + + return async (req, res) => { + const { templatePath } = req.params; + + if (!templatePath) { + res.status(200).set({ 'Content-Type': 'text/html' }).end(buildTemplateIndex()); + return; + } + + const calculatedPath = `@preview/${sessionToken}/${templatePath}`; + let templateInfo; + try { + templateInfo = await fetchTemplatesByPath(accountId, calculatedPath); + } catch (err) { + logger.error(`Failed to fetch template info for ${calculatedPath}: ${err}`); + } + if (!templateInfo || !('previewKey' in templateInfo)) { + res.status(502).set({ 'Content-Type': 'text/html' }).end(buildErrorIndex()); + return; + } + const params = { + template_file_path: calculatedPath, + hs_preview_key: templateInfo.previewKey, + ...req.query + } + const previewUrl = new URL(getPreviewUrl(sessionInfo, params)); + const result = await http.get(accountId, { + baseUrl: previewUrl.origin, + uri: previewUrl.pathname, + query: params, + json: false, + useQuerystring: true, + }); + const html = addRefreshScript(result) + res.status(200).set({ 'Content-Type': 'text/html' }).end(html); + } +} + +const buildErrorIndex = () => buildHTMLResponse(` + <div> + <h2>Error</h2> + <p>Failed to fetch template data.</p> + </div> +`) + +const buildTemplateIndex = () => buildHTMLResponse(` + <div> + <h2>Template</h2> + <p>Please provide a template path in the request</p> + </div> +`) + +module.exports = { + buildTemplateRouteHandler +} diff --git a/lib/preview/sprocketMenuServer.js b/lib/preview/sprocketMenuServer.js new file mode 100644 index 0000000..8b4fad3 --- /dev/null +++ b/lib/preview/sprocketMenuServer.js @@ -0,0 +1,83 @@ +const net = require('net'); +const express = require('express'); +const { Router } = require('express'); +const cors = require("cors"); +const { logger } = require('../../logger'); + +const SPROCKET_MENU_PORT = 1442; + +const startSprocketMenuServer = async (sessionInfo) => { + const portIsTaken = await new Promise((res, rej) => { + const testNetServer = net.createServer(); + + testNetServer.once('error', err => { + if (err['code'] === 'EADDRINUSE') { + logger.error( + `Port ${SPROCKET_MENU_PORT} is in use. HubSpot will be unable to automatically create proxy links in the Sprocket Menu` + ); + res(true); + } else { + rej(err); + } + }); + + testNetServer.once('listening', () => { + testNetServer.close(); + }); + + testNetServer.once('close', () => { + res(false); + }); + + testNetServer.listen(SPROCKET_MENU_PORT); + }); + + if (portIsTaken) { + return; + } + + const sprocketMenuServer = express(); + + sprocketMenuServer.listen(SPROCKET_MENU_PORT); + + sprocketMenuServer.use( + '/', + await createSprocketMenuServerRoutes(sessionInfo) + ); +} + +const createSprocketMenuServerRoutes = async (sessionInfo) => { + const sprocketMenuServerRoutes = Router(); + + sprocketMenuServerRoutes.get( + '/check-if-local-dev-server', + cors(), + sprocketMenuServerCheckHandler(sessionInfo), + ); + + return sprocketMenuServerRoutes; +} + +const sprocketMenuServerCheckHandler = (sessionInfo) => async (req, res) => { + const { PORT } = sessionInfo; + const { query } = req; + + if (query) { + const { + hostName, + pathName, + } = query; + + let hasJSBuildingBlocks = false; + let localProxyUrl = `http://${hostName}.hslocal.net:${PORT}${pathName}`; + + res + .status(200) + .set({ 'Content-Type': 'application/json' }) + .json({ hasJSBuildingBlocks, localProxyUrl }); +}; +} + +module.exports = { + startSprocketMenuServer +} diff --git a/lib/uploadFolder.js b/lib/uploadFolder.js index ab5d5a9..2d1f96c 100644 --- a/lib/uploadFolder.js +++ b/lib/uploadFolder.js @@ -107,6 +107,46 @@ async function getFilesByType( return [filePathsByType, fieldsJsObjects]; } +const defaultUploadAttemptCallback = ((file, destPath) => + logger.debug( + 'Attempting to upload file "%s" to "%s"', + file, + destPath + ) +); +const defaultUploadSuccessCallback = (file, destPath) => logger.log( + 'Uploaded file "%s" to "%s"', + file, + destPath +); +const defaultUploadFirstErrorCallback = (file, destPath, error) => { + logger.debug( + 'Uploading file "%s" to "%s" failed so scheduled retry', + file, + destPath + ); + if (error.response && error.response.body) { + logger.debug(error.response.body); + } else { + logger.debug(error.message); + } +}; +const defaultUploadRetryCallback = (file, destPath) => logger.debug( + 'Retrying to upload file "%s" to "%s"', + file, + destPath +); +const defaultUploadFinalErrorCallback = (accountId, file, destPath, error) => { + logger.error('Uploading file "%s" to "%s" failed', file, destPath); + logApiUploadErrorInstance( + error, + new ApiErrorContext({ + accountId, + request: destPath, + payload: file, + }) + ); +}; /** * * @param {number} accountId @@ -122,7 +162,20 @@ async function uploadFolder( commandOptions = {}, filePaths = [] ) { - const { saveOutput, convertFields } = commandOptions; + const { + saveOutput, + convertFields, + onAttemptCallback, + onSuccessCallback, + onFirstErrorCallback, + onRetryCallback, + onFinalErrorCallback + } = commandOptions; + const _onAttemptCallback = onAttemptCallback || defaultUploadAttemptCallback; + const _onSuccessCallback = onSuccessCallback || defaultUploadSuccessCallback; + const _onFirstErrorCallback = onFirstErrorCallback || defaultUploadFirstErrorCallback; + const _onRetryCallback = onRetryCallback || defaultUploadRetryCallback; + const _onFinalErrorCallback = onFinalErrorCallback || defaultUploadFinalErrorCallback; const tmpDir = convertFields ? createTmpDirSync('hubspot-temp-fieldsjs-output-') : null; @@ -162,28 +215,15 @@ async function uploadFolder( ); const destPath = convertToUnixPath(path.join(dest, relativePath)); return async () => { - logger.debug( - 'Attempting to upload file "%s" to "%s"', - originalFilePath, - destPath - ); + _onAttemptCallback(originalFilePath, destPath); try { await upload(accountId, file, destPath, apiOptions); - logger.log('Uploaded file "%s" to "%s"', originalFilePath, destPath); + _onSuccessCallback(originalFilePath, destPath); } catch (error) { if (isFatalError(error)) { throw error; } - logger.debug( - 'Uploading file "%s" to "%s" failed so scheduled retry', - file, - destPath - ); - if (error.response && error.response.body) { - logger.debug(error.response.body); - } else { - logger.debug(error.message); - } + _onFirstErrorCallback(file, destPath, error); failures.push({ file, destPath, @@ -202,28 +242,20 @@ async function uploadFolder( .addAll( failures.map(({ file, destPath }) => { return async () => { - logger.debug('Retrying to upload file "%s" to "%s"', file, destPath); + _onRetryCallback(file, destPath); try { await upload(accountId, file, destPath, apiOptions); - logger.log('Uploaded file "%s" to "%s"', file, destPath); + _onSuccessCallback(file, destPath); return { resultType: FileUploadResultType.SUCCESS, error: null, file, }; } catch (error) { - logger.error('Uploading file "%s" to "%s" failed', file, destPath); if (isFatalError(error)) { throw error; } - logApiUploadErrorInstance( - error, - new ApiErrorContext({ - accountId, - request: destPath, - payload: file, - }) - ); + _onFinalErrorCallback(accountId, file, destPath, error); return { resultType: FileUploadResultType.FAILURE, error, diff --git a/package.json b/package.json index a0b3604..44d346b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "scripts": { "check-main": "branch=$(git rev-parse --abbrev-ref HEAD) && [ $branch = main ] || (echo 'Error: New release can only be published on main branch' && exit 1)", "lint": "eslint . && prettier --list-different packages/**/*.{js,json}", - "prettier:write": "prettier --write packages/**/*.{js,json}", + "prettier:write": "prettier --write **/*.{js,json}", "pub": "npm publish --tag latest", "push": "git push --atomic origin main v$npm_package_version", "release:major": "yarn check-main && yarn version --major && yarn pub && yarn push", @@ -24,13 +24,16 @@ "chalk": "^2.4.2", "chokidar": "^3.0.1", "content-disposition": "^0.5.3", + "cors": "^2.8.5", "debounce": "^1.2.0", + "express": "^4.18.2", "extract-zip": "^1.6.7", "findup-sync": "^4.0.0", "fs-extra": "^8.1.0", "ignore": "^5.1.4", "jest": "^29.5.0", "js-yaml": "^4.1.0", + "mkcert-cli": "^1.5.0", "moment": "^2.24.0", "p-queue": "^6.0.2", "prettier": "^1.19.1", diff --git a/personalAccessKey.js b/personalAccessKey.js index 07dc2eb..39e0d1f 100644 --- a/personalAccessKey.js +++ b/personalAccessKey.js @@ -49,6 +49,7 @@ async function getAccessToken( accessToken: response.oauthAccessToken, expiresAt: moment(response.expiresAtMillis), scopeGroups: response.scopeGroups, + enabledFeatures: response.enabledFeatures, encodedOauthRefreshToken: response.encodedOauthRefreshToken, }; } @@ -58,11 +59,12 @@ async function refreshAccessToken( personalAccessKey, env = ENVIRONMENTS.PROD ) { - const { accessToken, expiresAt } = await getAccessToken( + const accessTokenResponse = await getAccessToken( personalAccessKey, env, accountId ); + const { accessToken, expiresAt } = accessTokenResponse const config = getAccountConfig(accountId); updateAccountConfig({ @@ -75,15 +77,15 @@ async function refreshAccessToken( }); writeConfig(); - return accessToken; + return accessTokenResponse; } -async function getNewAccessToken(accountId, personalAccessKey, expiresAt, env) { +async function getNewAccessToken(accountId, personalAccessKey, expiresAt, env, fullAPIResponse=false) { const key = getRefreshKey(personalAccessKey, expiresAt); if (refreshRequests.has(key)) { return refreshRequests.get(key); } - let accessToken; + let accessTokenResponse; try { const refreshAccessPromise = refreshAccessToken( accountId, @@ -93,14 +95,17 @@ async function getNewAccessToken(accountId, personalAccessKey, expiresAt, env) { if (key) { refreshRequests.set(key, refreshAccessPromise); } - accessToken = await refreshAccessPromise; + accessTokenResponse = await refreshAccessPromise; } catch (e) { if (key) { refreshRequests.delete(key); } throw e; } - return accessToken; + if (fullAPIResponse) { + return accessTokenResponse; + } + return accessTokenResponse.accessToken; } /** @@ -129,6 +134,20 @@ async function accessTokenForPersonalAccessKey(accountId) { return auth.tokenInfo.accessToken; } +async function enabledFeaturesForPersonalAccessKey(accountId) { + const { auth, personalAccessKey, env } = getAccountConfig(accountId); + const authTokenInfo = auth && auth.tokenInfo; + + const accessTokenResponse = await getNewAccessToken( + accountId, + personalAccessKey, + authTokenInfo && authTokenInfo.expiresAt, + env, + fullAPIResponse=true + ) + return accessTokenResponse.enabledFeatures; +} + /** * @deprecated @@ -194,6 +213,7 @@ const updateConfigWithPersonalAccessKey = async (configData, makeDefault) => { module.exports = { accessTokenForPersonalAccessKey, + enabledFeaturesForPersonalAccessKey, updateConfigWithPersonalAccessKey, getAccessToken, };