Source

server/routes.js

  1. /**
  2. * This namespace exports multiple utility functions for setting up routes
  3. * ```javascript
  4. * import { upstreamQuintypeRoutes, isomorphicRoutes, getWithConfig, proxyGetRequest } from "@quintype/framework/server/routes";
  5. * ```
  6. * @category Server
  7. * @module routes
  8. */
  9. const { match } = require('path-to-regexp')
  10. const { generateServiceWorker } = require('./handlers/generate-service-worker')
  11. const {
  12. handleIsomorphicShell,
  13. handleIsomorphicDataLoad,
  14. handleIsomorphicRoute,
  15. handleStaticRoute,
  16. notFoundHandler
  17. } = require('./handlers/isomorphic-handler')
  18. const { oneSignalImport } = require('./handlers/one-signal')
  19. const { customRouteHandler } = require('./handlers/custom-route-handler')
  20. const { handleManifest, handleAssetLink } = require('./handlers/json-manifest-handlers')
  21. const { redirectStory } = require('./handlers/story-redirect')
  22. const { simpleJsonHandler } = require('./handlers/simple-json-handler')
  23. const { makePickComponentSync } = require('../isomorphic/impl/make-pick-component-sync')
  24. const { registerFCMTopic } = require('./handlers/fcm-registration-handler')
  25. const { triggerWebengageNotifications } = require('./handlers/webengage-notifications')
  26. const rp = require('request-promise')
  27. const bodyParser = require('body-parser')
  28. const get = require('lodash/get')
  29. const { URL } = require('url')
  30. const prerender = require('@quintype/prerender-node')
  31. /**
  32. * *upstreamQuintypeRoutes* connects various routes directly to the upstream API server.
  33. *
  34. * Requests like */api/** and */stories.rss* are directly forwarded, but also it is also possible to forward other routes.
  35. * @param {Express} app The express app to add the routes to
  36. * @param {Object} opts Options
  37. * @param {Array<string>} opts.extraRoutes Additionally forward some routes upstream. This takes an array of express compatible routes, such as ["/foo/*"]
  38. * @param {boolean} opts.forwardAmp Forward amp story routes upstream (default false)
  39. * @param {number} opts.sMaxAge Support overriding of proxied response cache header `s-maxage` from Sketches. For Breaking News and if the cacheability is Private, it is not overwritten instead the cache control will be the same as how it's set in sketches. We can set `upstreamRoutesSmaxage: 900` under `publisher` in publisher.yml config file that comes from BlackKnight or pass sMaxAge as a param.
  40. * @param {number} opts.maxAge Support overriding of proxied response cache header `maxage` from Sketches. For Breaking News and if the cacheability is Private, it is not overwritten instead the cache control will be the same as how it's set in sketches. We can set `upstreamRoutesMaxage: 15` under `publisher` in publisher.yml config file that comes from BlackKnight or pass maxAge as a param.
  41. * @param {boolean} opts.forwardFavicon Forward favicon requests to the CMS (default false)
  42. * @param {boolean} opts.isSitemapUrlEnabled To enable /news_sitemap/today and /news_sitemap/yesterday sitemap news url (default /news_sitemap.xml)
  43. */
  44. exports.upstreamQuintypeRoutes = function upstreamQuintypeRoutes (
  45. app,
  46. {
  47. forwardAmp = false,
  48. forwardFavicon = false,
  49. extraRoutes = [],
  50. sMaxAge,
  51. maxAge,
  52. config = require('./publisher-config'),
  53. getClient = require('./api-client').getClient,
  54. isSitemapUrlEnabled = false
  55. } = {}
  56. ) {
  57. const host = config.sketches_host
  58. const get = require('lodash/get')
  59. const apiProxy = require('http-proxy').createProxyServer({
  60. target: host,
  61. ssl: host.startsWith('https') ? { servername: host.replace(/^https:\/\//, '') } : undefined
  62. })
  63. apiProxy.on('proxyReq', (proxyReq, req, res, options) => {
  64. proxyReq.setHeader('Host', getClient(req.hostname).getHostname())
  65. })
  66. const _sMaxAge = get(config, ['publisher', 'upstreamRoutesSmaxage'], sMaxAge)
  67. const _maxAge = get(config, ['publisher', 'upstreamRoutesMaxage'], maxAge)
  68. parseInt(_sMaxAge) > 0 &&
  69. apiProxy.on('proxyRes', function (proxyRes, req) {
  70. const pathName = get(req, ['originalUrl'], '').split('?')[0]
  71. const checkForExcludeRoutes = excludeRoutes.some(path => {
  72. const matchFn = match(path, { decode: decodeURIComponent })
  73. return matchFn(pathName)
  74. })
  75. const getCacheControl = get(proxyRes, ['headers', 'cache-control'], '')
  76. if (!checkForExcludeRoutes && getCacheControl.includes('public')) {
  77. proxyRes.headers['cache-control'] = getCacheControl.replace(/s-maxage=\d*/g, `s-maxage=${_sMaxAge}`)
  78. }
  79. })
  80. parseInt(_maxAge) > 0 &&
  81. apiProxy.on('proxyRes', function (proxyRes, req) {
  82. const pathName = get(req, ['originalUrl'], '').split('?')[0]
  83. const checkForExcludeRoutes = excludeRoutes.some(path => {
  84. const matchFn = match(path, { decode: decodeURIComponent })
  85. return matchFn(pathName)
  86. })
  87. const getCacheControl = get(proxyRes, ['headers', 'cache-control'], '')
  88. if (!checkForExcludeRoutes && getCacheControl.includes('public')) {
  89. proxyRes.headers['cache-control'] = getCacheControl.replace(/max-age=\d*/g, `max-age=${_maxAge}`)
  90. }
  91. })
  92. const sketchesProxy = (req, res) => apiProxy.web(req, res)
  93. app.get('/ping', (req, res) => {
  94. getClient(req.hostname)
  95. .getConfig()
  96. .then(() => res.send('pong'))
  97. .catch(() => res.status(503).send({ error: { message: 'Config not loaded' } }))
  98. })
  99. // Mention the routes which don't want to override the s-maxage value and max-age value
  100. const excludeRoutes = [
  101. '/qlitics.js',
  102. '/api/v1/breaking-news',
  103. '/stories.rss',
  104. '/api/v1/collections/:slug.rss',
  105. '/api/v1/advanced-search',
  106. '/api/instant-articles.rss'
  107. ]
  108. app.all('/api/*', sketchesProxy)
  109. app.all('*/api/*', sketchesProxy)
  110. app.all('/login', sketchesProxy)
  111. app.all('/qlitics.js', sketchesProxy)
  112. app.all('/auth.form', sketchesProxy)
  113. app.all('/auth.callback', sketchesProxy)
  114. app.all('/auth', sketchesProxy)
  115. app.all('/admin/*', sketchesProxy)
  116. app.all('/sitemap.xml', sketchesProxy)
  117. app.all('/sitemap/*', sketchesProxy)
  118. app.all('/feed', sketchesProxy)
  119. app.all('/rss-feed', sketchesProxy)
  120. app.all('/stories.rss', sketchesProxy)
  121. app.all('/sso-login', sketchesProxy)
  122. app.all('/sso-signup', sketchesProxy)
  123. if (isSitemapUrlEnabled) {
  124. app.all('/news_sitemap/today.xml', sketchesProxy)
  125. app.all('/news_sitemap/yesterday.xml', sketchesProxy)
  126. } else {
  127. app.all('/news_sitemap.xml', sketchesProxy)
  128. }
  129. if (forwardAmp) {
  130. app.get('/amp/*', sketchesProxy)
  131. }
  132. if (forwardFavicon) {
  133. app.get('/favicon.ico', sketchesProxy)
  134. }
  135. extraRoutes.forEach(route => app.all(route, sketchesProxy))
  136. }
  137. // istanbul ignore next
  138. function renderServiceWorkerFn (res, layout, params, callback) {
  139. return res.render(layout, params, callback)
  140. }
  141. // istanbul ignore next
  142. function toFunction (value, toRequire) {
  143. if (value === true) {
  144. value = require(toRequire)
  145. }
  146. if (typeof value === 'function') {
  147. return value
  148. }
  149. return () => value
  150. }
  151. function getDomainSlug (publisherConfig, hostName) {
  152. if (!publisherConfig.domain_mapping) {
  153. return undefined
  154. }
  155. return publisherConfig.domain_mapping[hostName] || null
  156. }
  157. function withConfigPartial (
  158. getClient,
  159. logError,
  160. publisherConfig = require('./publisher-config'),
  161. configWrapper = config => config
  162. ) {
  163. return function withConfig (f, staticParams) {
  164. return function (req, res, next) {
  165. const domainSlug = getDomainSlug(publisherConfig, req.hostname)
  166. const client = getClient(req.hostname)
  167. return client
  168. .getConfig()
  169. .then(config => configWrapper(config, domainSlug, { req }))
  170. .then(config =>
  171. f(
  172. req,
  173. res,
  174. next,
  175. Object.assign({}, staticParams, {
  176. config,
  177. client,
  178. domainSlug
  179. })
  180. )
  181. )
  182. .catch(logError)
  183. }
  184. }
  185. }
  186. exports.withError = function withError (handler, logError) {
  187. return async (req, res, next, opts) => {
  188. try {
  189. await handler(req, res, next, opts)
  190. } catch (e) {
  191. logError(e)
  192. res.status(500)
  193. res.end()
  194. }
  195. }
  196. }
  197. function convertToDomain (path) {
  198. if (!path) {
  199. return path
  200. }
  201. return new URL(path).origin
  202. }
  203. function wrapLoadDataWithMultiDomain (publisherConfig, f, configPos) {
  204. return async function loadDataWrapped () {
  205. const { domainSlug } = arguments[arguments.length - 1]
  206. const config = arguments[configPos]
  207. const primaryHostUrl = convertToDomain(config['sketches-host'])
  208. const domain = (config.domains || []).find(d => d.slug === domainSlug) || {
  209. 'host-url': primaryHostUrl
  210. }
  211. const result = await f.apply(this, arguments)
  212. return Object.assign(
  213. {
  214. domainSlug,
  215. currentHostUrl: convertToDomain(domain['host-url']),
  216. primaryHostUrl
  217. },
  218. result
  219. )
  220. }
  221. }
  222. /**
  223. * A handler is an extension of an express handler. Handlers are declared with the following arguments
  224. * ```javascript
  225. * function handler(req, res, next, { config, client, ...opts }) {
  226. * // do something cool
  227. * }
  228. * ```
  229. * @typedef Handler
  230. */
  231. /**
  232. * Use *getWithConfig* to handle GET requests. The handle that is accepted is of type {@link module:routes~Handler}, which is similar to an express
  233. * handler, but already has a *client* initialized, and the *config* fetched from the server.
  234. *
  235. * @param {Express} app Express app to add the route to
  236. * @param {string} route The route to implement
  237. * @param {module:routes~Handler} handler The Handler to run
  238. * @param {Object} opts Options that will be passed to the handler. These options will be merged with a *config* and *client*
  239. */
  240. function getWithConfig (app, route, handler, opts = {}) {
  241. const configWrapper = opts.configWrapper
  242. const {
  243. getClient = require('./api-client').getClient,
  244. publisherConfig = require('./publisher-config'),
  245. logError = require('./logger').error
  246. } = opts
  247. const withConfig = withConfigPartial(getClient, logError, publisherConfig, configWrapper)
  248. app.get(route, withConfig(handler, opts))
  249. }
  250. /**
  251. * *isomorphicRoutes* brings all the moving parts of the [server side rendering](https://developers.quintype.com/malibu/isomorphic-rendering/server-side-architecture) together.
  252. * It accepts all the pieces needed, and implements all the plumbing to make these pieces work together.
  253. *
  254. * Note that *isomorphicRoutes* adds a route that matches *&#47;&ast;*, so it should be near the end of your *app/server/app.js*.
  255. *
  256. * @param {Express} app Express app to add the routes to
  257. * @param {Object} opts Options
  258. * @param {function} opts.generateRoutes A function that generates routes to be matched given a config. See [routing](https://developers.quintype.com/malibu/isomorphic-rendering/server-side-architecture#routing) for more information. This call should be memoized, as it's called on every request
  259. * @param {function} opts.renderLayout A function that renders the layout given the content injected by *isomorphicRoutes*. See [renderLayout](https://developers.quintype.com/malibu/isomorphic-rendering/server-side-architecture#renderlayout)
  260. * @param {function} opts.loadData An async function that loads data for the page, given the *pageType*. See [loadData](https://developers.quintype.com/malibu/isomorphic-rendering/server-side-architecture#loaddata)
  261. * @param {function} opts.pickComponent An async function that picks the correct component for rendering each *pageType*. See [pickComponent](https://developers.quintype.com/malibu/isomorphic-rendering/server-side-architecture#pickcomponent)
  262. * @param {function} opts.loadErrorData An async function that loads data if there is an error. If *handleNotFound* is set to true, this function is also called to load data for the 404 page
  263. * @param {SEO} opts.seo An SEO object that will generate html tags for each page. See [@quintype/seo](https://developers.quintype.com/malibu/isomorphic-rendering/server-side-architecture#quintypeseo)
  264. * @param {function} opts.manifestFn An async function that accepts the *config*, and returns content for the *&#47;manifest.json*. Common fields like *name*, *start_url* will be populated by default, but can be owerwritten. If not set, then manifest will not be generated.
  265. * @param {function} opts.assetLinkFn An async function that accepts *config* and returns *{ packageName, authorizedKeys }* for the Android *&#47;.well-known/assetlinks.json*. If not implemented, then AssetLinks will return a 404.
  266. * @param {boolean} opts.oneSignalServiceWorkers Deprecated: If set to true, then generate *&#47;OneSignalSKDWorker.js* which combines the Quintype worker as well as OneSignal's worker. (default: false). Please see [https://developers.quintype.com/malibu/tutorial/onesignal](https://developers.quintype.com/malibu/tutorial/onesignal)
  267. * @param {*} opts.staticRoutes WIP: List of static routes
  268. * @param {Array<string>} opts.serviceWorkerPaths List of paths to host the service worker on (default: ["/service-worker.js"])s
  269. * @param {number} opts.appVersion The version of this app. In case there is a version mismatch between server and client, then client will update ServiceWorker in the background. See *app/isomorphic/app-version.js*.
  270. * @param {boolean} opts.preloadJs Return a *Link* header preloading JS files. In h/2 compatible browsers, this Js will be pushed. (default: false)
  271. * @param {boolean} opts.preloadRouteData Return a *Link* header preloading *&#47;route-data.json*. In h/2 compatible browsers, this Js will be pushed. (default: false)
  272. * @param {boolean} opts.handleCustomRoute If the page is not matched as an isomorphic route, then match against a static page or redirect in the CMS, and behave accordingly. Note, this runs after the isomorphic routes, so any live stories or sections will take precedence over a redirection set up in the editor. (default: true)
  273. * @param {boolean} opts.handleNotFound If set to true, then handle 404 pages with *pageType* set to *"not-found"*. (default: true)
  274. * @param {boolean} opts.redirectRootLevelStories If set to true, then stories URLs without a section (at *&#47;:storySlug*) will redirect to the canonical url (default: false)
  275. * @param {boolean} opts.mobileApiEnabled If set to true, then *&#47;mobile-data.json* will respond to mobile API requests. This is primarily used by the React Native starter kit. (default: true)
  276. * @param {Object} opts.mobileConfigFields List of fields that are needed in the config field of the *&#47;mobile-data.json* API. This is primarily used by the React Native starter kit. (default: {})
  277. * @param {boolean} opts.templateOptions If set to true, then *&#47;template-options.json* will return a list of available components so that components can be sorted in the CMS. This reads data from *config/template-options.yml*. See [Adding a homepage component](https://developers.quintype.com/malibu/tutorial/adding-a-homepage-component) for more details
  278. * @param {boolean|function} opts.lightPages If set to true, then all story pages will render amp pages.
  279. * @param {string | function} opts.cdnProvider The name of the cdn provider. Supported cdn providers are akamai, cloudflare. Default value is cloudflare.
  280. * @param {function} opts.maxConfigVersion An async function which resolves to a integer version of the config. This defaults to config.theme-attributes.cache-burst
  281. * @param {Array<object>|function} opts.redirectUrls An array or async function which used to render the redirect url provided in the array of object - >ex- REDIRECT_URLS = [{sourceUrl: "/tag/:tagSlug",destinationUrl: "/topic/:tagSlug",statusCode: 301,}]
  282. * @param {boolean|function} redirectToLowercaseSlugs If set or evaluates to true, then for every story-page request having capital latin letters in the slug, it responds with a 301 redirect to the lowercase slug URL. (default: true)
  283. * @param {boolean|function} shouldEncodeAmpUri If set to true, then for every story-page request the slug will be encoded, in case of a vernacular slug this should be set to false. Receives path as param (default: true)
  284. * @param {number} sMaxAge Overrides the s-maxage value, the default value is set to 900 seconds. We can set `isomorphicRoutesSmaxage: 900` under `publisher` in publisher.yml config file that comes from BlackKnight or pass sMaxAge as a param.
  285. * @param {number} maxAge Overrides the max-age value, the default value is set to 15 seconds. We can set `isomorphicRoutesMaxage: 15` under `publisher` in publisher.yml config file that comes from BlackKnight or pass maxAge as a param.
  286. * @param {(object|function)} fcmServiceCreds FCM service creds is used for registering FCM Topic.
  287. * @param {string} appLoadingPlaceholder This string gets injected into the app container when the page is loaded via service worker. Can be used to show skeleton layouts, animations or other progress indicators before it is replaced by the page content.
  288. * @param {boolean|function} enableExternalStories If set to true, then for every request an external story api call is made and renders the story-page if the story is found. (default: false)
  289. * @param {string|function} externalIdPattern This string specifies the external id pattern the in the url. Mention `EXTERNAL_ID` to specify the position of external id in the url. Ex: "/parent-section/child-section/EXTERNAL_ID"
  290. */
  291. exports.isomorphicRoutes = function isomorphicRoutes (
  292. app,
  293. {
  294. generateRoutes,
  295. renderLayout,
  296. loadData,
  297. pickComponent,
  298. loadErrorData,
  299. seo,
  300. manifestFn,
  301. assetLinkFn,
  302. ampPageBasePath = '/amp/story',
  303. oneSignalServiceWorkers = false,
  304. staticRoutes = [],
  305. appVersion = 1,
  306. preloadJs = false,
  307. preloadRouteData = false,
  308. handleCustomRoute = true,
  309. handleNotFound = true,
  310. redirectRootLevelStories = false,
  311. mobileApiEnabled = true,
  312. mobileConfigFields = {},
  313. templateOptions = false,
  314. lightPages = false,
  315. cdnProvider = 'cloudflare',
  316. serviceWorkerPaths = ['/service-worker.js'],
  317. maxConfigVersion = config => get(config, ['theme-attributes', 'cache-burst'], 0),
  318. configWrapper = config => config,
  319. // The below are primarily for testing
  320. logError = require('./logger').error,
  321. assetHelper = require('./asset-helper'),
  322. getClient = require('./api-client').getClient,
  323. renderServiceWorker = renderServiceWorkerFn,
  324. publisherConfig = require('./publisher-config'),
  325. redirectUrls = [],
  326. prerenderServiceUrl = '',
  327. redirectToLowercaseSlugs = false,
  328. shouldEncodeAmpUri,
  329. sMaxAge = 900,
  330. maxAge = 15,
  331. appLoadingPlaceholder = '',
  332. fcmServiceCreds = {},
  333. webengageConfig = {},
  334. externalIdPattern = '',
  335. enableExternalStories = false,
  336. lazyLoadImageMargin
  337. }
  338. ) {
  339. const withConfig = withConfigPartial(getClient, logError, publisherConfig, configWrapper)
  340. const _sMaxAge = parseInt(get(publisherConfig, ['publisher', 'isomorphicRoutesSmaxage'], sMaxAge))
  341. const _maxAge = parseInt(get(publisherConfig, ['publisher', 'isomorphicRoutesMaxage'], maxAge))
  342. pickComponent = makePickComponentSync(pickComponent)
  343. loadData = wrapLoadDataWithMultiDomain(publisherConfig, loadData, 2)
  344. loadErrorData = wrapLoadDataWithMultiDomain(publisherConfig, loadErrorData, 1)
  345. if (prerenderServiceUrl) {
  346. app.use((req, res, next) => {
  347. if (req.query.prerender) {
  348. try {
  349. // eslint-disable-next-line global-require
  350. prerender.set('protocol', 'https')
  351. prerender.set('prerenderServiceUrl', prerenderServiceUrl)(req, res, next)
  352. } catch (e) {
  353. logError(e)
  354. }
  355. } else {
  356. next()
  357. }
  358. })
  359. }
  360. app.use((req, res, next) => {
  361. const origin = req.headers.origin
  362. const allowedOriginRegex = /^https?:\/\/([a-zA-Z0-9-]+\.)*quintype\.com$/
  363. if (allowedOriginRegex.test(origin)) {
  364. res.setHeader('Access-Control-Allow-Origin', origin)
  365. res.setHeader('Access-Control-Allow-Methods', 'GET')
  366. res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
  367. if (req.method === 'OPTIONS') {
  368. res.sendStatus(204)
  369. return
  370. }
  371. }
  372. next()
  373. })
  374. if (serviceWorkerPaths.length > 0) {
  375. app.get(
  376. serviceWorkerPaths,
  377. withConfig(generateServiceWorker, {
  378. generateRoutes,
  379. assetHelper,
  380. renderServiceWorker,
  381. maxConfigVersion
  382. })
  383. )
  384. }
  385. if (oneSignalServiceWorkers) {
  386. app.get(
  387. '/OneSignalSDKWorker.js',
  388. withConfig(generateServiceWorker, {
  389. generateRoutes,
  390. renderServiceWorker,
  391. assetHelper,
  392. appendFn: oneSignalImport,
  393. maxConfigVersion
  394. })
  395. )
  396. app.get(
  397. '/OneSignalSDKUpdaterWorker.js',
  398. withConfig(generateServiceWorker, {
  399. generateRoutes,
  400. renderServiceWorker,
  401. assetHelper,
  402. appendFn: oneSignalImport,
  403. maxConfigVersion
  404. })
  405. )
  406. }
  407. app.get(
  408. '/shell.html',
  409. withConfig(handleIsomorphicShell, {
  410. seo,
  411. renderLayout,
  412. assetHelper,
  413. loadData,
  414. loadErrorData,
  415. logError,
  416. preloadJs,
  417. maxConfigVersion,
  418. appLoadingPlaceholder
  419. })
  420. )
  421. app.get(
  422. '/route-data.json',
  423. withConfig(handleIsomorphicDataLoad, {
  424. generateRoutes,
  425. loadData,
  426. loadErrorData,
  427. logError,
  428. staticRoutes,
  429. seo,
  430. appVersion,
  431. cdnProvider,
  432. redirectToLowercaseSlugs,
  433. sMaxAge: _sMaxAge,
  434. maxAge: _maxAge,
  435. networkOnly: true
  436. })
  437. )
  438. app.post('/register-fcm-topic', bodyParser.json(), withConfig(registerFCMTopic, { publisherConfig, fcmServiceCreds }))
  439. if (webengageConfig.enableWebengage) {
  440. app.post(
  441. '/integrations/webengage/trigger-notification',
  442. bodyParser.json(),
  443. withConfig(triggerWebengageNotifications, webengageConfig)
  444. )
  445. }
  446. if (manifestFn) {
  447. app.get('/manifest.json', withConfig(handleManifest, { manifestFn, logError }))
  448. }
  449. if (mobileApiEnabled) {
  450. app.get(
  451. '/mobile-data.json',
  452. withConfig(handleIsomorphicDataLoad, {
  453. generateRoutes,
  454. loadData,
  455. loadErrorData,
  456. logError,
  457. staticRoutes,
  458. seo,
  459. appVersion,
  460. mobileApiEnabled,
  461. mobileConfigFields,
  462. cdnProvider,
  463. redirectToLowercaseSlugs,
  464. sMaxAge: _sMaxAge,
  465. maxAge: _maxAge
  466. })
  467. )
  468. }
  469. if (assetLinkFn) {
  470. app.get('/.well-known/assetlinks.json', withConfig(handleAssetLink, { assetLinkFn, logError }))
  471. }
  472. if (templateOptions) {
  473. app.get(
  474. '/template-options.json',
  475. withConfig(simpleJsonHandler, {
  476. jsonData: toFunction(templateOptions, './impl/template-options')
  477. })
  478. )
  479. }
  480. staticRoutes.forEach(route => {
  481. app.get(
  482. route.path,
  483. withConfig(
  484. handleStaticRoute,
  485. Object.assign(
  486. {
  487. logError,
  488. loadData,
  489. loadErrorData,
  490. renderLayout,
  491. seo,
  492. cdnProvider,
  493. oneSignalServiceWorkers,
  494. publisherConfig,
  495. sMaxAge: _sMaxAge,
  496. maxAge: _maxAge
  497. },
  498. route
  499. )
  500. )
  501. )
  502. })
  503. app.get(
  504. '/*',
  505. withConfig(handleIsomorphicRoute, {
  506. generateRoutes,
  507. loadData,
  508. renderLayout,
  509. pickComponent,
  510. loadErrorData,
  511. seo,
  512. logError,
  513. preloadJs,
  514. preloadRouteData,
  515. assetHelper,
  516. cdnProvider,
  517. lightPages,
  518. redirectUrls,
  519. redirectToLowercaseSlugs,
  520. shouldEncodeAmpUri,
  521. oneSignalServiceWorkers,
  522. publisherConfig,
  523. sMaxAge: _sMaxAge,
  524. maxAge: _maxAge,
  525. ampPageBasePath,
  526. externalIdPattern,
  527. enableExternalStories,
  528. lazyLoadImageMargin
  529. })
  530. )
  531. if (redirectRootLevelStories) {
  532. app.get('/:storySlug', withConfig(redirectStory, { logError, cdnProvider, sMaxAge: _sMaxAge, maxAge: _maxAge }))
  533. }
  534. if (handleCustomRoute) {
  535. app.get(
  536. '/*',
  537. withConfig(customRouteHandler, {
  538. loadData,
  539. renderLayout,
  540. logError,
  541. seo,
  542. cdnProvider,
  543. sMaxAge: _sMaxAge,
  544. maxAge: _maxAge
  545. })
  546. )
  547. }
  548. if (handleNotFound) {
  549. app.get(
  550. '/*',
  551. withConfig(notFoundHandler, {
  552. renderLayout,
  553. pickComponent,
  554. loadErrorData,
  555. logError,
  556. seo,
  557. assetHelper
  558. })
  559. )
  560. }
  561. }
  562. exports.getWithConfig = getWithConfig
  563. /**
  564. * *proxyGetRequest* can be used to forward requests to another host, and cache the results on our CDN. This can be done as follows in `app/server/app.js`.
  565. *
  566. * ```javascript
  567. * proxyGetRequest(app, "/path/to/:resource.json", (params) => `https://example.com/${params.resource}.json`, {logError})
  568. * ```
  569. *
  570. * The handler can return the following:
  571. * * null / undefined - The result will be a 503
  572. * * any truthy value - The result will be returned as a 200 with the result as content
  573. * * A url starting with http(s) - The URL will be fetched and content will be returned according to the above two rules
  574. * @param {Express} app The app to add the route to
  575. * @param {string} route The new route
  576. * @param {function} handler A function which takes params and returns a URL to proxy
  577. * @param opts
  578. * @param opts.cacheControl The cache control header to set on proxied requests (default: *"public,max-age=15,s-maxage=240,stale-while-revalidate=300,stale-if-error=3600"*)
  579. */
  580. exports.proxyGetRequest = function (app, route, handler, opts = {}) {
  581. const { logError = require('./logger').error } = opts
  582. const { cacheControl = 'public,max-age=15,s-maxage=240,stale-while-revalidate=300,stale-if-error=3600' } = opts
  583. getWithConfig(app, route, proxyHandler, opts)
  584. async function proxyHandler (req, res, next, { config, client }) {
  585. try {
  586. const result = await handler(req.params, { config, client })
  587. if (typeof result === 'string' && result.startsWith('http')) {
  588. sendResult(await rp(result, { json: true }))
  589. } else {
  590. sendResult(result)
  591. }
  592. } catch (e) {
  593. logError(e)
  594. sendResult(null)
  595. }
  596. function sendResult (result) {
  597. if (result) {
  598. res.setHeader('Cache-Control', cacheControl)
  599. res.setHeader('Vary', 'Accept-Encoding')
  600. res.json(result)
  601. } else {
  602. res.status(503)
  603. res.end()
  604. }
  605. }
  606. }
  607. }
  608. // This could also be done using express's mount point, but /ping stops working
  609. exports.mountQuintypeAt = function (app, mountAt) {
  610. app.use(function (req, res, next) {
  611. const mountPoint = typeof mountAt === 'function' ? mountAt(req.hostname) : mountAt
  612. if (mountPoint && req.url.startsWith(mountPoint)) {
  613. req.url = req.url.slice(mountPoint.length) || '/'
  614. next()
  615. } else if (mountPoint && req.url !== '/ping') {
  616. res.status(404).send(`Not Found: Quintype has been mounted at ${mountPoint}`)
  617. } else {
  618. next()
  619. }
  620. })
  621. }
  622. /**
  623. * *ampRoutes* handles all the amp page routes using the *[@quintype/amp](https://developers.quintype.com/quintype-node-amp)* library
  624. * routes matched:
  625. * GET - "/amp/:slug"* returns amp story page
  626. * GET - "/amp/api/v1/amp-infinite-scroll" returns the infinite scroll config JSON. Passed to <amp-next-page> component's `src` attribute
  627. *
  628. * To disable amp version for a specific story, you need to create a story attribute in bold with the slug {disable-amp-for-single-story} and values {true} and {false}. Set its value to "true" in the story which you want to disable amp. Please make sure to name the attributes and values in the exact same way as mentioned
  629. * attribute slug: "disable-amp-for-single-story" values: "true" , "false". This will redirect '<amp-page-base-path>/:slug' to the non-amp page
  630. *
  631. * @param {Express} app Express app to add the routes to
  632. * @param {Object} opts Options object used to configure amp. Passing this is optional
  633. * @param {Object} opts.templates An object that's used to pass custom templates. Each key corresponds to the template name and corresponding value is the template
  634. * @param {Object} opts.slots An object used to pass slot data.
  635. * @param {SEO} opts.seo An SEO object that will generate html tags for each page. See [@quintype/seo](https://developers.quintype.com/malibu/isomorphic-rendering/server-side-architecture#quintypeseo)
  636. * @param {boolean|function} opts.enableAmp 'amp/story/:slug' should redirect to non-amp page if enableAmp is false
  637. * @param {object|function} opts.redirectUrls list of urls which is used to redirect URL(sourceUrl) to a different URL(destinationUrl). Eg: redirectUrls: { "/amp/story/sports/ipl-2021": {destinationUrl: "/amp/story/sports/cricket-2022", statusCode: 302,},}
  638. * @param {function} opts.headerCardRender Render prop for story headerCard. If passed, the headerCard in default stories will be replaced with this
  639. * @param {function} opts.relatedStoriesRender Render prop for relatedStories in a story page. If passed, this will replace the related stories
  640. *
  641. */
  642. exports.ampRoutes = (app, opts = {}) => {
  643. const { ampStoryPageHandler, storyPageInfiniteScrollHandler } = require('./amp/handlers')
  644. getWithConfig(app, '/amp/api/v1/amp-infinite-scroll', storyPageInfiniteScrollHandler, opts)
  645. getWithConfig(app, '/ampstories/*', ampStoryPageHandler, { ...opts, isVisualStory: true })
  646. getWithConfig(app, '/*', ampStoryPageHandler, opts)
  647. }