diff --git a/.circleci/config.yml b/.circleci/config.yml index 40e916d303..f96d93aa50 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -180,6 +180,7 @@ workflows: only: - develop - PM-3686_group-submissions-in-challenge-details + - payload-cms - "build-prod": context: org-global diff --git a/__tests__/server/contentful-endpoints.js b/__tests__/server/contentful-endpoints.js new file mode 100644 index 0000000000..db788d4c3e --- /dev/null +++ b/__tests__/server/contentful-endpoints.js @@ -0,0 +1,31 @@ +import { + getContentfulApiBaseUrl, + getContentfulApiHost, +} from 'server/services/contentful-endpoints'; + +describe('server/services/contentful-endpoints', () => { + test('retains Contentful Delivery and Preview hosts by default', () => { + expect(getContentfulApiHost({}, false)).toBe('cdn.contentful.com'); + expect(getContentfulApiHost({}, true)).toBe('preview.contentful.com'); + }); + + test('uses configured compatibility hosts and normalizes URL syntax', () => { + const environment = { + CDN_API_HOST: 'https://cms.topcoder-dev.com/', + PREVIEW_API_HOST: 'cms.topcoder-dev.com', + }; + + expect(getContentfulApiHost(environment, false)).toBe('cms.topcoder-dev.com'); + expect(getContentfulApiHost(environment, true)).toBe('cms.topcoder-dev.com'); + }); + + test('builds the Contentful-compatible spaces and environments path', () => { + expect(getContentfulApiBaseUrl('cms.topcoder-dev.com', 'space id', 'feature/test')) + .toBe('https://cms.topcoder-dev.com/spaces/space%20id/environments/feature%2Ftest'); + }); + + test('rejects non-string configured hosts', () => { + expect(() => getContentfulApiHost({ CDN_API_HOST: true }, false)) + .toThrow('CDN_API_HOST must be a hostname string.'); + }); +}); diff --git a/__tests__/server/contentful.js b/__tests__/server/contentful.js new file mode 100644 index 0000000000..360097ee8d --- /dev/null +++ b/__tests__/server/contentful.js @@ -0,0 +1,51 @@ +/* eslint-env jest */ + +import { createClient as createDeliveryClient } from 'contentful'; +import { + articleVote, + getService, +} from 'server/services/contentful'; + +const contentfulManagement = require('contentful-management'); + +jest.mock('contentful', () => ({ + createClient: jest.fn(() => ({})), +})); + +jest.mock('contentful-management', () => ({ + createClient: jest.fn(() => ({ + getSpace: jest.fn(() => Promise.resolve({ + getEnvironment: jest.fn(() => Promise.resolve({ + getEntry: jest.fn(() => Promise.resolve({ + fields: {}, + update: jest.fn(() => Promise.resolve({ + publish: jest.fn(() => Promise.resolve({ published: true })), + })), + })), + })), + })), + })), +})); + +describe('server/services/contentful HTTPS connections', () => { + test('shares one keep-alive agent across Delivery, Preview, and Management clients', async () => { + getService('default', 'master', false); + + const deliveryAgents = createDeliveryClient.mock.calls + .map(call => call[0].httpsAgent); + + expect(deliveryAgents.length).toBeGreaterThan(1); + deliveryAgents.forEach((agent) => { + expect(agent).toBe(deliveryAgents[0]); + expect(agent.options.keepAlive).toBe(true); + }); + + await articleVote({ + id: 'article-id', + votes: { downvotes: 1, upvotes: 2 }, + }); + + const managementConfig = contentfulManagement.createClient.mock.calls[0][0]; + expect(managementConfig.httpsAgent).toBe(deliveryAgents[0]); + }); +}); diff --git a/__tests__/shared/containers/EDUTrackCards.jsx b/__tests__/shared/containers/EDUTrackCards.jsx new file mode 100644 index 0000000000..1fdec356d7 --- /dev/null +++ b/__tests__/shared/containers/EDUTrackCards.jsx @@ -0,0 +1,86 @@ +import React from 'react'; +import Renderer from 'react-test-renderer/shallow'; +import TrackCards from 'containers/EDU/partials/TrackCards'; + +const EXPECTED_SELECT = [ + 'sys.id', + 'sys.type', + 'fields.externalArticle', + 'fields.contentUrl', + 'fields.slug', + 'fields.title', + 'fields.tags', + 'fields.readTime', + 'fields.creationDate', + 'fields.upvotes', + 'fields.commentsCount', + 'fields.featuredImage', + 'fields.contentAuthor', + 'fields.file', +].join(','); + +test('selects only fields required by EDU track cards and their assets', () => { + const renderer = new Renderer(); + renderer.render(); + + expect(renderer.getRenderOutput().props.entryQueries).toEqual({ + content_type: 'article', + 'fields.trackCategory': 'Development', + limit: 3, + order: '-sys.createdAt', + select: EXPECTED_SELECT, + }); +}); + +test('passes a projected article and resolved Asset file to Article small', () => { + const trackCardsRenderer = new Renderer(); + trackCardsRenderer.render( + , + ); + const trackCardsLoader = trackCardsRenderer.getRenderOutput(); + const article = { + fields: { + commentsCount: 2, + contentAuthor: [{ sys: { id: 'author-id', linkType: 'Entry', type: 'Link' } }], + creationDate: '2026-08-12', + featuredImage: { sys: { id: 'asset-id', linkType: 'Asset', type: 'Link' } }, + readTime: '5 min', + slug: 'projected-article', + tags: ['Payload'], + title: 'Projected article', + upvotes: 3, + }, + sys: { id: 'article-id', type: 'Entry' }, + }; + const cards = trackCardsLoader.props.render({ + entries: { items: { 'article-id': article } }, + }); + + const articleLoaderRenderer = new Renderer(); + articleLoaderRenderer.render(cards.props.children[0]); + const articleLoader = articleLoaderRenderer.getRenderOutput(); + expect(articleLoader.props.entryIds).toBe('article-id'); + + const articleAssetsRenderer = new Renderer(); + articleAssetsRenderer.render(articleLoader.props.render({ + entries: { items: { 'article-id': article } }, + })); + const assetLoader = articleAssetsRenderer.getRenderOutput(); + expect(assetLoader.props.assetIds).toBe('asset-id'); + + const articleCard = assetLoader.props.render({ + assets: { + items: { + 'asset-id': { + fields: { + file: { url: '//assets.topcoder-dev.com/media/contentful/projected.png' }, + }, + }, + }, + }, + }); + expect(articleCard.props.article.title).toBe('Projected article'); + expect(articleCard.props.featuredImage.file.url) + .toBe('//assets.topcoder-dev.com/media/contentful/projected.png'); + expect(articleCard.props.themeName).toBe('Article small'); +}); diff --git a/__tests__/shared/containers/TopcoderHeader.jsx b/__tests__/shared/containers/TopcoderHeader.jsx index d0adbfdc62..0f9daccc90 100644 --- a/__tests__/shared/containers/TopcoderHeader.jsx +++ b/__tests__/shared/containers/TopcoderHeader.jsx @@ -1,7 +1,8 @@ import _ from 'lodash'; -import Container from 'containers/TopcoderHeader'; +import Container, { TopcoderHeader } from 'containers/TopcoderHeader'; import React from 'react'; import R from 'react-test-renderer/shallow'; +import { config } from 'topcoder-react-utils'; const mockState = { auth: { @@ -27,3 +28,19 @@ test('Matches shallow snapshot', () => { )); expect(r.getRenderOutput()).toMatchSnapshot(); }); + +test('Passes the configured universal navigation URL to the navigation loader', () => { + const r = new R(); + r.render(( + + )); + + expect(r.getRenderOutput().props.children.props.uniNavUrl) + .toBe(config.UNIVERSAL_NAV_URL); +}); diff --git a/__tests__/shared/routes/TopcoderRoutes.jsx b/__tests__/shared/routes/TopcoderRoutes.jsx new file mode 100644 index 0000000000..64e639cd44 --- /dev/null +++ b/__tests__/shared/routes/TopcoderRoutes.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import Renderer from 'react-test-renderer/shallow'; +import { Route, Switch, matchPath } from 'react-router-dom'; +import { config } from 'topcoder-react-utils'; + +import ContentfulRoute from 'components/Contentful/Route'; +import Footer from 'components/TopcoderFooter'; +import Header from 'containers/TopcoderHeader'; +import EDUHome from 'routes/EDUHome'; +import EDUSearch from 'routes/EDUSearch'; +import EDUTracks from 'routes/EDUTracks'; +import Topcoder from 'routes/Topcoder/Routes'; + +test('matches exact Thrive routes before the generic root Contentful route', () => { + const renderer = new Renderer(); + renderer.render(); + + const output = renderer.getRenderOutput(); + const children = React.Children.toArray(output.props.children); + const routeSwitch = children[1]; + const routes = React.Children.toArray(routeSwitch.props.children); + const contentfulRouteIndex = routes.findIndex(route => route.type === ContentfulRoute); + const expectedRoutes = [ + { + component: EDUHome, + path: config.TC_EDU_BASE_PATH, + pathname: config.TC_EDU_BASE_PATH, + }, + { + component: EDUTracks, + path: `${config.TC_EDU_BASE_PATH}${config.TC_EDU_TRACKS_PATH}`, + pathname: `${config.TC_EDU_BASE_PATH}${config.TC_EDU_TRACKS_PATH}`, + }, + { + component: EDUSearch, + path: `${config.TC_EDU_BASE_PATH}${config.TC_EDU_SEARCH_PATH}`, + pathname: `${config.TC_EDU_BASE_PATH}${config.TC_EDU_SEARCH_PATH}`, + }, + { + path: `${config.TC_EDU_BASE_PATH}${config.TC_EDU_ARTICLES_PATH}/:articleTitle`, + pathname: `${config.TC_EDU_BASE_PATH}${config.TC_EDU_ARTICLES_PATH}/routing-test`, + }, + ]; + + expect(children[0].type).toBe(Header); + expect(routeSwitch.type).toBe(Switch); + expect(children[2].type).toBe(Footer); + expect(contentfulRouteIndex).toBeGreaterThan(-1); + + expectedRoutes.forEach((expectedRoute) => { + const routeIndex = routes.findIndex(route => ( + route.type === Route && route.props.path === expectedRoute.path + )); + const route = routes[routeIndex]; + + expect(routeIndex).toBeGreaterThan(-1); + expect(routeIndex).toBeLessThan(contentfulRouteIndex); + expect(route.props.exact).toBe(true); + expect(matchPath(expectedRoute.pathname, route.props)).not.toBeNull(); + if (expectedRoute.component) { + expect(route.props.component).toBe(expectedRoute.component); + } + }); +}); diff --git a/config/custom-environment-variables.js b/config/custom-environment-variables.js index a6dac4008d..81141676dc 100644 --- a/config/custom-environment-variables.js +++ b/config/custom-environment-variables.js @@ -30,6 +30,7 @@ module.exports = { SERVER_API_KEY: 'SERVER_API_KEY', URL: { + CMS_ASSETS: 'PAYLOAD_CMS_ASSET_URL', COMMUNITY_APP: 'COMMUNITY_APP_URL', EMAIL_VERIFY_URL: 'EMAIL_VERIFY_URL', }, @@ -37,11 +38,15 @@ module.exports = { SECRET: { CONTENTFUL: { MANAGEMENT_TOKEN: 'CONTENTFUL_MANAGEMENT_TOKEN', + PAYLOAD_VOTE_API_URL: 'CONTENTFUL_PAYLOAD_VOTE_API_URL', + PAYLOAD_MANAGEMENT_API_KEY: 'CONTENTFUL_PAYLOAD_MANAGEMENT_API_KEY', default: { SPACE_ID: 'CONTENTFUL_SPACE_ID', master: { CDN_API_KEY: 'CONTENTFUL_CDN_API_KEY', PREVIEW_API_KEY: 'CONTENTFUL_PREVIEW_API_KEY', + CDN_API_HOST: 'CONTENTFUL_CDN_API_HOST', + PREVIEW_API_HOST: 'CONTENTFUL_PREVIEW_API_HOST', }, }, zurich: { @@ -56,6 +61,8 @@ module.exports = { master: { CDN_API_KEY: 'CONTENTFUL_TOPGEAR_CDN_API_KEY', PREVIEW_API_KEY: 'CONTENTFUL_TOPGEAR_PREVIEW_API_KEY', + CDN_API_HOST: 'CONTENTFUL_TOPGEAR_CDN_API_HOST', + PREVIEW_API_HOST: 'CONTENTFUL_TOPGEAR_PREVIEW_API_HOST', }, }, EDU: { @@ -63,6 +70,8 @@ module.exports = { master: { CDN_API_KEY: 'CONTENTFUL_EDU_CDN_API_KEY', PREVIEW_API_KEY: 'CONTENTFUL_EDU_PREVIEW_API_KEY', + CDN_API_HOST: 'CONTENTFUL_EDU_CDN_API_HOST', + PREVIEW_API_HOST: 'CONTENTFUL_EDU_PREVIEW_API_HOST', }, }, comcast: { diff --git a/config/default.js b/config/default.js index ec826e3614..064d814dc8 100644 --- a/config/default.js +++ b/config/default.js @@ -111,6 +111,7 @@ module.exports = { /* This is the same value as above, but it is used by topcoder-react-lib, * as a more verbose name for the param. */ COMMUNITY_APP: 'https://community-app.topcoder-dev.com', + CMS_ASSETS: 'https://assets.topcoder-dev.com', CHALLENGES_URL: 'https://www.topcoder-dev.com/challenges', COPILOTS_URL: 'https://copilots.topcoder-dev.com', ENGAGEMENTS_APP: 'https://engagements.topcoder-dev.com', @@ -211,11 +212,17 @@ module.exports = { DEFAULT_SPACE_NAME: 'default', DEFAULT_ENVIRONMENT: 'master', MANAGEMENT_TOKEN: '', // Personal Access Token to use the Content Management API + /* Optional Payload write-through endpoint. When unset, article votes + * continue to use the Contentful Management API. */ + PAYLOAD_VOTE_API_URL: '', + PAYLOAD_MANAGEMENT_API_KEY: '', default: { // Human-readable name of space SPACE_ID: '', master: { // Name of an environment CDN_API_KEY: '', PREVIEW_API_KEY: '', + CDN_API_HOST: '', + PREVIEW_API_HOST: '', }, }, EDU: { @@ -223,6 +230,8 @@ module.exports = { master: { CDN_API_KEY: '', PREVIEW_API_KEY: '', + CDN_API_HOST: '', + PREVIEW_API_HOST: '', }, }, /* Space for expert communities. */ @@ -239,6 +248,8 @@ module.exports = { master: { CDN_API_KEY: '', PREVIEW_API_KEY: '', + CDN_API_HOST: '', + PREVIEW_API_HOST: '', }, }, comcast: { diff --git a/config/production.js b/config/production.js index f805153f72..92deada92a 100644 --- a/config/production.js +++ b/config/production.js @@ -28,6 +28,7 @@ module.exports = { /* This is the same value as above, but it is used by topcoder-react-lib, * as a more verbose name for the param. */ COMMUNITY_APP: 'https://community-app.topcoder.com', + CMS_ASSETS: 'https://assets.topcoder.com', CHALLENGES_URL: 'https://www.topcoder.com/challenges', COPILOTS_URL: 'https://copilots.topcoder.com', ENGAGEMENTS_APP: 'https://engagements.topcoder.com', diff --git a/docs/contentful/environment-setup.md b/docs/contentful/environment-setup.md index 7f75a2b67c..2582ca9103 100644 --- a/docs/contentful/environment-setup.md +++ b/docs/contentful/environment-setup.md @@ -37,5 +37,45 @@ To run Community App locally against your Contentful account: $ source ./set-contentful-env.sh $ NODE_CONFIG_ENV=development npm run dev ``` + We have prepared a demo env file you could use to start. You can find it + [here](https://gist.github.com/kkartunov/594dc65f76bac6aa800b4764cae72d2e). - We have prepared a demo env file you could use to start. You can find it [here](https://gist.github.com/kkartunov/594dc65f76bac6aa800b4764cae72d2e). +### Using the Payload CMS compatibility API + +Community App can migrate spaces independently while retaining Contentful for +spaces that have not been exported. Set the Delivery and Preview host variables +only for the migrated spaces; values are hostnames without a path. Existing API +keys remain the bearer credentials for the compatibility API. + +```bash +# Default space +export CONTENTFUL_CDN_API_HOST="cms.topcoder-dev.com" +export CONTENTFUL_PREVIEW_API_HOST="cms.topcoder-dev.com" + +# EDU space +export CONTENTFUL_EDU_CDN_API_HOST="cms.topcoder-dev.com" +export CONTENTFUL_EDU_PREVIEW_API_HOST="cms.topcoder-dev.com" + +# TopGear space +export CONTENTFUL_TOPGEAR_CDN_API_HOST="cms.topcoder-dev.com" +export CONTENTFUL_TOPGEAR_PREVIEW_API_HOST="cms.topcoder-dev.com" + +# Public S3/CloudFront origin returned for migrated asset bytes +export PAYLOAD_CMS_ASSET_URL="https://assets.topcoder-dev.com" +``` + +Zurich and Comcast continue to use Contentful unless host variables are added +for those spaces in a later migration. To store EDU article votes in Payload, +also set the full write endpoint and its service credential: + +```bash +export CONTENTFUL_PAYLOAD_VOTE_API_URL="https://cms.topcoder-dev.com/contentful-management/votes" +export CONTENTFUL_PAYLOAD_MANAGEMENT_API_KEY="" +``` + +When the Payload vote URL is unset, Community App retains the existing +Contentful Management API update-and-publish behavior. + +`PAYLOAD_CMS_ASSET_URL` is added to the server's image and media Content +Security Policy directives. Set it to the environment-specific Payload asset +origin; do not include a path. diff --git a/src/server/index.js b/src/server/index.js index 795b51d485..e390087559 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -238,6 +238,7 @@ async function onExpressJsSetup(server) { + ' https://d1of0acg2orgco.cloudfront.net' + ' https://d24oibycet9bsb.cloudfront.net' + ' https://d2nl5eqipnb33q.cloudfront.net' + + ` ${config.URL.CMS_ASSETS}` + ' https://images.ctfassets.net' + ' https://heapanalytics.com' + ' https://q.quora.com' @@ -254,7 +255,8 @@ async function onExpressJsSetup(server) { + ' https://track.hubspot.com' + ' https://d0.awsstatic.com/logos/;' + " manifest-src 'self';" - + " media-src 'self';" + + " media-src 'self'" + + ` ${config.URL.CMS_ASSETS};` + " worker-src 'self';", ); } diff --git a/src/server/services/contentful-endpoints.js b/src/server/services/contentful-endpoints.js new file mode 100644 index 0000000000..00697aea24 --- /dev/null +++ b/src/server/services/contentful-endpoints.js @@ -0,0 +1,47 @@ +/** + * Endpoint helpers shared by the server-side Contentful Delivery and Preview + * clients. They allow selected spaces to use a Contentful-compatible host + * without changing how other spaces are configured. + */ + +const CONTENTFUL_CDN_API_HOST = 'cdn.contentful.com'; +const CONTENTFUL_PREVIEW_API_HOST = 'preview.contentful.com'; + +/** + * Resolves the API hostname for one Contentful space environment. + * + * @param {Object} environmentConfig The environment's API key and optional + * host configuration. + * @param {Boolean} preview Whether the caller needs the Preview API host. + * @return {String} A hostname suitable for both the Contentful SDK and an + * HTTPS URL. This is used while constructing every server-side CMS client. + * @throws {TypeError} If a configured host is not a string. + */ +export function getContentfulApiHost(environmentConfig, preview) { + const property = preview ? 'PREVIEW_API_HOST' : 'CDN_API_HOST'; + const fallback = preview ? CONTENTFUL_PREVIEW_API_HOST : CONTENTFUL_CDN_API_HOST; + const configuredHost = environmentConfig[property]; + + if (configuredHost === undefined || configuredHost === null || configuredHost === '') { + return fallback; + } + if (typeof configuredHost !== 'string') { + throw new TypeError(`${property} must be a hostname string.`); + } + + return configuredHost.replace(/^https?:\/\//i, '').replace(/\/+$/, ''); +} + +/** + * Builds a Contentful-compatible API base URL for direct HTTP requests. + * + * @param {String} host API hostname returned by getContentfulApiHost(). + * @param {String} spaceId Contentful space identifier. + * @param {String} environment Contentful environment name. + * @return {String} The HTTPS base URL used by ApiService.fetch(). + * @throws {URIError} If the space identifier or environment cannot be URL + * encoded. + */ +export function getContentfulApiBaseUrl(host, spaceId, environment) { + return `https://${host}/spaces/${encodeURIComponent(spaceId)}/environments/${encodeURIComponent(environment)}`; +} diff --git a/src/server/services/contentful.js b/src/server/services/contentful.js index 3ea968a50e..07dacd3ac8 100644 --- a/src/server/services/contentful.js +++ b/src/server/services/contentful.js @@ -6,17 +6,26 @@ import _ from 'lodash'; import config from 'config'; import { createClient } from 'contentful'; +import https from 'https'; +import fetch from 'isomorphic-fetch'; import { logger } from 'topcoder-react-lib'; import { isomorphy } from 'topcoder-react-utils'; import { qs } from 'qs'; +import { + getContentfulApiBaseUrl, + getContentfulApiHost, +} from './contentful-endpoints'; const contentful = require('contentful-management'); -/* Holds Contentful CDN URL. */ -const CDN_URL = 'https://cdn.contentful.com/spaces'; - -/* Holds Contentful Preview URL. */ -const PREVIEW_URL = 'https://preview.contentful.com/spaces'; +/** + * Process-wide HTTPS connection pool shared by every server-side Contentful + * Delivery, Preview, and Management SDK client. Node 10 does not enable + * keep-alive on its default agent, so reusing this agent avoids a new TCP/TLS + * connection for each CMS request while leaving browser requests unchanged. + * @type {https.Agent} + */ +const contentfulHttpsAgent = new https.Agent({ keepAlive: true }); export const ASSETS_DOMAIN = 'assets.ctfassets.net'; export const IMAGES_DOMAIN = 'images.ctfassets.net'; @@ -55,18 +64,20 @@ class ApiService { * @param {String} key API key. * @param {String} spaceId The space id. * @param {Boolean} preview Use the preview API? + * @param {String} host Contentful-compatible API hostname. */ - constructor(baseUrl, key, spaceId, preview) { + constructor(baseUrl, key, spaceId, preview, host) { this.private = { - baseUrl, key, spaceId, preview, + baseUrl, key, spaceId, preview, host, }; // client config const clientConf = { accessToken: key, + httpsAgent: contentfulHttpsAgent, space: spaceId, logHandler, + host, }; - if (preview) clientConf.host = 'preview.contentful.com'; // create the client to work with this.client = createClient(clientConf); } @@ -144,13 +155,45 @@ class ApiService { /** * Updates votes count in Contentful articles - * @param {Object} body - * @param {String} body.id - * @param {Object} body.votes + * @param {Object} body Vote update submitted by Community App. + * @param {String} body.id EDU article entry identifier. + * @param {Object} body.votes Updated upvote and downvote totals. + * @return {Promise} The updated Contentful entry when using Contentful, + * or the Payload endpoint's JSON response when write-through is configured. + * This is used by the authenticated article vote proxy route. + * @throws {Error} If Payload write-through is enabled without an API key, the + * Payload endpoint rejects the request, or the Contentful update fails. */ export function articleVote(body) { + const payloadUrl = config.SECRET.CONTENTFUL.PAYLOAD_VOTE_API_URL; + if (payloadUrl) { + const apiKey = config.SECRET.CONTENTFUL.PAYLOAD_MANAGEMENT_API_KEY; + if (!apiKey) { + return Promise.reject(new Error('CONTENTFUL_PAYLOAD_MANAGEMENT_API_KEY is required when Payload article voting is enabled.')); + } + return fetch(payloadUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + spaceId: config.SECRET.CONTENTFUL.EDU.SPACE_ID, + environment: 'master', + entryId: body.id, + votes: body.votes, + }), + }).then((response) => { + if (!response.ok) { + throw new Error(`Payload article vote update failed with status ${response.status}.`); + } + return response.json(); + }); + } + const client = contentful.createClient({ accessToken: config.SECRET.CONTENTFUL.MANAGEMENT_TOKEN, + httpsAgent: contentfulHttpsAgent, }); return client.getSpace(config.SECRET.CONTENTFUL.EDU.SPACE_ID) .then(space => space.getEnvironment('master')) @@ -184,6 +227,7 @@ let services; function initServiceInstances() { const contentfulConfig = _.omit(config.SECRET.CONTENTFUL, [ 'DEFAULT_SPACE_NAME', 'DEFAULT_ENVIRONMENT', 'MANAGEMENT_TOKEN', + 'PAYLOAD_VOTE_API_URL', 'PAYLOAD_MANAGEMENT_API_KEY', ]); services = {}; _.map(contentfulConfig, (spaceConfig, spaceName) => { @@ -192,14 +236,18 @@ function initServiceInstances() { if (name !== 'SPACE_ID') { const environment = name; const spaceId = spaceConfig.SPACE_ID; - const previewBaseUrl = `${PREVIEW_URL}/${spaceId}/environments/${environment}`; - const cdnBaseUrl = `${CDN_URL}/${spaceId}/environments/${environment}`; + const previewHost = getContentfulApiHost(env, true); + const cdnHost = getContentfulApiHost(env, false); + const previewBaseUrl = getContentfulApiBaseUrl(previewHost, spaceId, environment); + const cdnBaseUrl = getContentfulApiBaseUrl(cdnHost, spaceId, environment); const svcs = {}; svcs.previewService = new ApiService( - previewBaseUrl.toString(), env.PREVIEW_API_KEY, spaceId, true, + previewBaseUrl, env.PREVIEW_API_KEY, spaceId, true, previewHost, + ); + svcs.cdnService = new ApiService( + cdnBaseUrl, env.CDN_API_KEY, spaceId, false, cdnHost, ); - svcs.cdnService = new ApiService(cdnBaseUrl.toString(), env.CDN_API_KEY, spaceId); services[spaceName][environment] = svcs; } }); diff --git a/src/shared/components/Contentful/Article/Article.jsx b/src/shared/components/Contentful/Article/Article.jsx index dd4b0f883f..9ad30ab03f 100644 --- a/src/shared/components/Contentful/Article/Article.jsx +++ b/src/shared/components/Contentful/Article/Article.jsx @@ -43,7 +43,7 @@ const CONTENT_PREVIEW_LENGTH = 110; // Votes local storage key const LOCAL_STORAGE_KEY = 'VENBcnRpY2xlVm90ZXM='; // def banner image -const DEFAULT_BANNER_IMAGE = 'https://images.ctfassets.net/piwi0eufbb2g/7v2hlDsVep7FWufHw0lXpQ/2505e61a880e68fab4e80cd0e8ec1814/0C37CB5E-B253-4804-8935-78E64E67589E.png?w=1200&h=630'; +const DEFAULT_BANNER_IMAGE = `${config.URL.CMS_ASSETS}/media/contentful/images.ctfassets.net/97/970c80d628d90c49e1a8817954d5ef8ddcffdf1b0a0e2cdb74a2decd7cabe8ee/0C37CB5E-B253-4804-8935-78E64E67589E-970c80d628d90c49e1a8817954d5ef8ddcffdf1b0a0e2cdb74a2decd7cabe8ee.png?w=1200&h=630`; // random ads banner - left sidebar const RANDOM_BANNERS = ['6G8mjiTC1mzeSQ2YoUG1gB', '1DnDD02xX1liHfSTf5Vsn8', 'HQZ3mN0rR92CbNTkKTHJ5', '1OLoX8ZsvjAnn4TdGbZESD', '77jn01UGoQe2gqA7x0coQD']; const RANDOM_BANNER = RANDOM_BANNERS[getSecureRandomIndex(RANDOM_BANNERS.length)]; @@ -202,7 +202,7 @@ class Article extends React.Component { } - Thrive banner shape + Thrive banner shape
{ diff --git a/src/shared/containers/GigsPages/index.jsx b/src/shared/containers/GigsPages/index.jsx index 5e5bd0431c..97ef458cf0 100644 --- a/src/shared/containers/GigsPages/index.jsx +++ b/src/shared/containers/GigsPages/index.jsx @@ -23,7 +23,7 @@ const optimizelyClient = createInstance({ }); const cookies = require('browser-cookies'); -const GIGS_SOCIAL_SHARE_IMAGE = 'https://images.ctfassets.net/b5f1djy59z3a/4XlYNZgq5Kfa4XdwQ6pDfV/769ea7be756a88145b88ce685f050ebc/10_Freelance_Gig.png'; +const GIGS_SOCIAL_SHARE_IMAGE = `${config.URL.CMS_ASSETS}/media/contentful/images.ctfassets.net/84/8416e431ed91f5e8234156c784e902c1723db1b4a24e3f6b62661f9b84811143/10_Freelance_Gig-8416e431ed91f5e8234156c784e902c1723db1b4a24e3f6b62661f9b84811143.png`; function GigsPagesContainer(props) { const { diff --git a/src/shared/containers/TopcoderHeader/index.jsx b/src/shared/containers/TopcoderHeader/index.jsx index ce478cb4f9..b8b859a421 100644 --- a/src/shared/containers/TopcoderHeader/index.jsx +++ b/src/shared/containers/TopcoderHeader/index.jsx @@ -8,7 +8,7 @@ import { MarketingNavigation, ToolNavigation } from 'uninav-react'; import { getSubPageConfiguration } from '../../utils/url'; import './styles.scss'; -const TopcoderHeader = ({ auth, location }) => { +export const TopcoderHeader = ({ auth, location }) => { const user = _.get(auth, 'profile') || {}; const authToken = _.get(auth, 'tokenV3'); const isAuthenticated = !!authToken; @@ -60,6 +60,7 @@ const TopcoderHeader = ({ auth, location }) => { return (
+ + {/* Keep Thrive routes ahead of the generic root Contentful route. The + * root route otherwise loads the default CMS route before falling + * through to its error404 switch. */} + + + + { + const { articleTitle } = p.match.params; + return ( + { + if (_.isEmpty(data.entries.items)) { + // try search by title match + // this legacy support should be deprecated when all + // Thrive links switched to hypens, someday + return ( + { + if (_.isEmpty(dataTitle.entries.items)) return ; + let id = dataTitle.entries.matches[0].items[0]; + if (dataTitle.entries.matches[0].total !== 1) { + // more than 1 match. we need to try find best + const mId = _.findKey( + dataTitle.entries.items, + // eslint-disable-next-line max-len + o => o.fields.title.toLocaleLowerCase() === articleTitle.toLocaleLowerCase(), + ); + id = mId || id; + } + const { + externalArticle, + contentUrl, + } = dataTitle.entries.items[id].fields; + if (externalArticle && contentUrl && isomorphy.isClientSide()) { + window.location.href = contentUrl; + return null; + } + return ( +
+ ); + }} + renderPlaceholder={LoadingIndicator} + /> + ); + } + const id = data.entries.matches[0].items[0]; + const { externalArticle, contentUrl } = data.entries.items[id].fields; + if (externalArticle && contentUrl && isomorphy.isClientSide()) { + window.location.href = contentUrl; + return null; + } + return ( +
+ ); + }} + renderPlaceholder={LoadingIndicator} + /> + ); + }} + exact + path={`${config.TC_EDU_BASE_PATH}${config.TC_EDU_ARTICLES_PATH}/:articleTitle`} + /> } /> - {/* EDU Portal */} - - - - { - const { articleTitle } = p.match.params; - return ( - { - if (_.isEmpty(data.entries.items)) { - // try search by title match - // this legacy support should be deprecated when all - // Thrive links switched to hypens, someday - return ( - { - if (_.isEmpty(dataTitle.entries.items)) return ; - let id = dataTitle.entries.matches[0].items[0]; - if (dataTitle.entries.matches[0].total !== 1) { - // more than 1 match. we need to try find best - const mId = _.findKey( - dataTitle.entries.items, - // eslint-disable-next-line max-len - o => o.fields.title.toLocaleLowerCase() === articleTitle.toLocaleLowerCase(), - ); - id = mId || id; - } - const { - externalArticle, - contentUrl, - } = dataTitle.entries.items[id].fields; - if (externalArticle && contentUrl && isomorphy.isClientSide()) { - window.location.href = contentUrl; - return null; - } - return ( -
- ); - }} - renderPlaceholder={LoadingIndicator} - /> - ); - } - const id = data.entries.matches[0].items[0]; - const { externalArticle, contentUrl } = data.entries.items[id].fields; - if (externalArticle && contentUrl && isomorphy.isClientSide()) { - window.location.href = contentUrl; - return null; - } - return ( -
- ); - }} - renderPlaceholder={LoadingIndicator} - /> - ); - }} - exact - path={`${config.TC_EDU_BASE_PATH}${config.TC_EDU_ARTICLES_PATH}/:articleTitle`} - /> )} diff --git a/src/shared/utils/url.js b/src/shared/utils/url.js index 117cb77947..d5c6d3e76f 100644 --- a/src/shared/utils/url.js +++ b/src/shared/utils/url.js @@ -185,7 +185,7 @@ export function getInitials(firstName = '', lastName = '') { return `${firstName.slice(0, 1)}${lastName.slice(0, 1)}`; } -export const DEFAULT_AVATAR_URL = 'https://images.ctfassets.net/b5f1djy59z3a/4PTwZVSf3W7qgs9WssqbVa/4c51312671a4b9acbdfd7f5e22320b62/default_avatar.svg'; +export const DEFAULT_AVATAR_URL = `${config.URL.CMS_ASSETS}/media/contentful/images.ctfassets.net/56/5628bff4d68faeeebce6cfcf14c8182774fa53fc6762e5429ca42d532d9ecb0d/default_avatar-5628bff4d68faeeebce6cfcf14c8182774fa53fc6762e5429ca42d532d9ecb0d.svg`; export const getSubPageConfiguration = (location, loginUserHandle) => { let toolName = 'Community';