From 75bbb0e05303354fff65e888954dc4398bb0a725 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 25 Aug 2026 14:47:14 +1000 Subject: [PATCH 1/3] Remove external CMS fallbacks from Payload migration --- Dockerfile | 4 +- README.md | 18 +- __tests__/server/cms-urls.js | 39 ++ __tests__/server/contentful-endpoints.js | 19 +- __tests__/server/contentful-routes.js | 57 +++ __tests__/server/contentful.js | 161 +++++++-- __tests__/shared/routes/TopcoderRoutes.jsx | 32 +- build.sh | 1 - config/custom-environment-variables.js | 2 +- config/default.js | 6 +- config/production.js | 1 + config/webpack/default.js | 11 + docs/contentful-configuration.md | 7 +- docs/contentful/environment-setup.md | 92 ++--- package-lock.json | 61 ---- package.json | 9 +- scripts/verify-no-retired-cms-targets.js | 63 ++++ src/client/shims/contentful-service.js | 10 + src/server/index.js | 9 +- src/server/routes/contentful.js | 70 +--- src/server/services/cms-urls.js | 121 +++++++ src/server/services/contentful-endpoints.js | 84 +++-- src/server/services/contentful.js | 339 ++++++------------ .../examples/MemberTalkCloudExample/index.jsx | 13 +- src/shared/routes/Topcoder/Routes.jsx | 105 +++--- src/shared/services/contentful.js | 1 + 26 files changed, 787 insertions(+), 548 deletions(-) create mode 100644 __tests__/server/cms-urls.js create mode 100644 __tests__/server/contentful-routes.js create mode 100644 scripts/verify-no-retired-cms-targets.js create mode 100644 src/client/shims/contentful-service.js create mode 100644 src/server/services/cms-urls.js diff --git a/Dockerfile b/Dockerfile index 250ceca78..7232f128e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,9 +28,8 @@ ARG CONTENTFUL_TOPGEAR_SPACE_ID ARG CONTENTFUL_TOPGEAR_CDN_API_KEY ARG CONTENTFUL_TOPGEAR_PREVIEW_API_KEY -#Credentials for Contentfu EDU space +# Credentials for the EDU compatibility space -ARG CONTENTFUL_MANAGEMENT_TOKEN ARG CONTENTFUL_EDU_SPACE_ID ARG CONTENTFUL_EDU_CDN_API_KEY ARG CONTENTFUL_EDU_PREVIEW_API_KEY @@ -119,7 +118,6 @@ ENV TC_M2M_AUTH0_URL=$TC_M2M_AUTH0_URL ENV AUTH_SECRET=$AUTH_SECRET ENV VALID_ISSUERS=$VALID_ISSUERS -ENV CONTENTFUL_MANAGEMENT_TOKEN=$CONTENTFUL_MANAGEMENT_TOKEN ENV CONTENTFUL_EDU_SPACE_ID=$CONTENTFUL_EDU_SPACE_ID ENV CONTENTFUL_EDU_CDN_API_KEY=$CONTENTFUL_EDU_CDN_API_KEY ENV CONTENTFUL_EDU_PREVIEW_API_KEY=$CONTENTFUL_EDU_PREVIEW_API_KEY diff --git a/README.md b/README.md index 4a1d2b72e..e4789d4b4 100644 --- a/README.md +++ b/README.md @@ -55,18 +55,18 @@ If you need any operations related to currency conversions, pay attention to the - `PORT` Specifies the port to run the App at. Defaults to 3000; - `NODE_CONFIG_ENV` Specifies Topcoder backend to use. Should be either `development` or `production`. Defaults to `production`. - Many app segments depend on [Contentful CMS](https://www.contentful.com/) - for routing information. Thus, even if the page/component you are working - with does not require CMS directly, you may see CMS-related error messages. - To interact with CMS you must setup the following environment variables: + CMS-backed segments use Payload's Contentful-compatible API. Application- + owned routes do not wait for CMS routing data. To use CMS content locally, + configure the legacy compatibility identifiers/keys and an explicit Payload + host; there is no external provider fallback: - `CONTENTFUL_SPACE_ID` - `CONTENTFUL_CDN_API_KEY` - `CONTENTFUL_PREVIEW_API_KEY` + - `CONTENTFUL_CDN_API_HOST` + - `CONTENTFUL_PREVIEW_API_HOST` - If you have access to Topcoder CMS space (or you use your own CMS space for - development), you'll find them under _Space settings_ > _API keys_. Otherwise, - look for these credentials in the challenge forum, or reach a copilot to get - them. + See [Payload CMS compatibility environment setup](docs/contentful/environment-setup.md) + for supported spaces, S3 asset configuration, and article-vote write-through. 5. To build the App's frontend run one of (the result of build will be output into `/build` folder in both cases): - `$ npm run build` To rebuild production frontend; @@ -198,4 +198,4 @@ given credentials relate to. ### Submitting Changes -Please check the [https://github.com/topcoder-platform/community-app/blob/master/CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on rules to be followed. \ No newline at end of file +Please check the [https://github.com/topcoder-platform/community-app/blob/master/CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on rules to be followed. diff --git a/__tests__/server/cms-urls.js b/__tests__/server/cms-urls.js new file mode 100644 index 000000000..5cb0ed075 --- /dev/null +++ b/__tests__/server/cms-urls.js @@ -0,0 +1,39 @@ +import { + assertNoRetiredCmsUrls, + getPayloadAppUrl, + getPayloadAssetUrl, +} from 'server/services/cms-urls'; + +describe('server/services/cms-urls', () => { + test('uses the configured Topcoder Payload application origin', () => { + expect(getPayloadAppUrl()).toBe('https://cms.topcoder-dev.com'); + }); + + test('allows only the configured S3-backed asset origin', () => { + expect(getPayloadAssetUrl('//assets.topcoder-dev.com/media/contentful/image.png')) + .toBe('https://assets.topcoder-dev.com/media/contentful/image.png'); + expect(() => getPayloadAssetUrl('https://example.com/image.png')) + .toThrow('outside PAYLOAD_CMS_ASSET_URL'); + }); + + test('rejects retired provider URLs in Asset records and rich text', () => { + expect(() => getPayloadAssetUrl('//images.ctfassets.net/space/asset/image.png')) + .toThrow('retired provider URL'); + expect(() => assertNoRetiredCmsUrls({ + fields: { body: '![old](https://images.ctfassets.net/space/asset/image.png)' }, + })).toThrow('retired provider URL'); + }); + + test.each([ + 'https://uat--topcoder.netlify.app/image.png', + 'https://quickedit.octana.io/preview', + ])('rejects other retired provider URL %s in Payload responses', (url) => { + expect(() => assertNoRetiredCmsUrls({ fields: { url } })) + .toThrow('retired provider URL'); + }); + + test('accepts unrelated links in CMS content', () => { + const content = { fields: { contentUrl: 'https://www.topcoder.com/challenges' } }; + expect(assertNoRetiredCmsUrls(content)).toBe(content); + }); +}); diff --git a/__tests__/server/contentful-endpoints.js b/__tests__/server/contentful-endpoints.js index db788d4c3..dfd86fa14 100644 --- a/__tests__/server/contentful-endpoints.js +++ b/__tests__/server/contentful-endpoints.js @@ -4,9 +4,11 @@ import { } 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('fails closed when a compatibility host is not configured', () => { + expect(() => getContentfulApiHost({}, false)) + .toThrow('CDN_API_HOST is required; external CMS fallbacks are disabled.'); + expect(() => getContentfulApiHost({}, true)) + .toThrow('PREVIEW_API_HOST is required; external CMS fallbacks are disabled.'); }); test('uses configured compatibility hosts and normalizes URL syntax', () => { @@ -19,11 +21,20 @@ describe('server/services/contentful-endpoints', () => { expect(getContentfulApiHost(environment, true)).toBe('cms.topcoder-dev.com'); }); - test('builds the Contentful-compatible spaces and environments path', () => { + test('builds the compatibility 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 provider, arbitrary, and path-bearing hosts', () => { + expect(() => getContentfulApiHost({ CDN_API_HOST: 'cdn.contentful.com' }, false)) + .toThrow('approved Topcoder Payload CMS host'); + expect(() => getContentfulApiHost({ CDN_API_HOST: 'cms.example.com' }, false)) + .toThrow('approved Topcoder Payload CMS host'); + expect(() => getContentfulApiHost({ CDN_API_HOST: 'cms.topcoder.com/path' }, false)) + .toThrow('must not include credentials, a path, query, or fragment'); + }); + 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-routes.js b/__tests__/server/contentful-routes.js new file mode 100644 index 000000000..9af43bbe8 --- /dev/null +++ b/__tests__/server/contentful-routes.js @@ -0,0 +1,57 @@ +/* eslint-env jest */ + +import express from 'express'; +import request from 'supertest'; +import { getService } from 'server/services/contentful'; +import routes from 'server/routes/contentful'; + +jest.mock('server/services/contentful', () => ({ + articleVote: jest.fn(), + getService: jest.fn(), +})); + +function createApp() { + const app = express(); + app.use(routes); + app.use((error, req, res, next) => { // eslint-disable-line no-unused-vars + res.status(502).send(error.message); + }); + return app; +} + +describe('server/routes/contentful legacy asset URLs', () => { + beforeEach(() => { + getService.mockReset(); + }); + + test('redirects an old image route only to the configured Payload asset origin', async () => { + const getAsset = jest.fn(() => Promise.resolve({ + fields: { file: { url: '//assets.topcoder-dev.com/media/contentful/image.png' } }, + })); + getService.mockReturnValue({ getAsset }); + + const response = await request(createApp()) + .get('/default/master/images/asset-id/version/image.png'); + + expect(response.status).toBe(302); + expect(response.headers.location) + .toBe('https://assets.topcoder-dev.com/media/contentful/image.png'); + expect(getService).toHaveBeenCalledWith('default', 'master', false); + expect(getAsset).toHaveBeenCalledWith('asset-id'); + }); + + test('does not redirect when Payload returns a retired provider URL', async () => { + getService.mockReturnValue({ + getAsset: jest.fn(() => Promise.resolve({ + fields: { file: { url: '//images.ctfassets.net/space/asset/image.png' } }, + })), + }); + + const response = await request(createApp()) + .get('/default/master/assets/asset-id/version/file.pdf'); + + expect(response.status).toBe(502); + expect(response.headers.location).toBeUndefined(); + expect(response.text).toContain('retired provider URL'); + }); +}); diff --git a/__tests__/server/contentful.js b/__tests__/server/contentful.js index 360097ee8..c639d06c4 100644 --- a/__tests__/server/contentful.js +++ b/__tests__/server/contentful.js @@ -1,51 +1,150 @@ /* eslint-env jest */ -import { createClient as createDeliveryClient } from 'contentful'; +import config from 'config'; +import fetch from 'isomorphic-fetch'; import { + ApiService, articleVote, getService, } from 'server/services/contentful'; -const contentfulManagement = require('contentful-management'); - -jest.mock('contentful', () => ({ - createClient: jest.fn(() => ({})), +jest.mock('config', () => ({ + CONTENTFUL: { + DEFAULT_ENVIRONMENT: 'master', + DEFAULT_SPACE_NAME: 'default', + }, + SECRET: { + CONTENTFUL: { + PAYLOAD_MANAGEMENT_API_KEY: 'management-key', + PAYLOAD_VOTE_API_URL: 'https://cms.topcoder-dev.com/contentful-management/votes', + default: { + SPACE_ID: 'default-space', + master: { + CDN_API_HOST: 'cms.topcoder-dev.com', + CDN_API_KEY: 'delivery-key', + PREVIEW_API_HOST: 'cms.topcoder-dev.com', + PREVIEW_API_KEY: 'preview-key', + }, + }, + EDU: { + SPACE_ID: 'edu-space', + master: { + CDN_API_HOST: 'cms.topcoder-dev.com', + CDN_API_KEY: 'edu-key', + PREVIEW_API_HOST: 'cms.topcoder-dev.com', + PREVIEW_API_KEY: 'edu-preview-key', + }, + }, + unsupported: { + SPACE_ID: 'unsupported-space', + master: { + CDN_API_HOST: '', + CDN_API_KEY: 'legacy-key', + PREVIEW_API_HOST: '', + PREVIEW_API_KEY: 'legacy-preview-key', + }, + }, + }, + }, })); -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 })), - })), - })), - })), - })), - })), -})); +jest.mock('isomorphic-fetch', () => jest.fn()); + +function response(data, status = 200) { + return { + json: jest.fn(() => Promise.resolve(data)), + ok: status >= 200 && status < 300, + status, + }; +} + +describe('server/services/contentful Payload compatibility client', () => { + beforeEach(() => { + fetch.mockReset(); + }); + + test('uses an explicit host, a shared keep-alive agent, and no redirects', async () => { + fetch.mockResolvedValue(response({ fields: { title: 'Asset' }, sys: { id: 'asset-id' } })); + + await getService('default', 'master', false).getAsset('asset-id'); + + expect(fetch.mock.calls[0][0]) + .toBe('https://cms.topcoder-dev.com/spaces/default-space/environments/master/assets/asset-id'); + const options = fetch.mock.calls[0][1]; + expect(options.redirect).toBe('manual'); + expect(options.agent.options.keepAlive).toBe(true); + expect(options.headers.Authorization).toBe('Bearer delivery-key'); + }); -describe('server/services/contentful HTTPS connections', () => { - test('shares one keep-alive agent across Delivery, Preview, and Management clients', async () => { - getService('default', 'master', false); + test('fails closed for an unsupported space before making a request', () => { + expect(() => getService('unsupported', 'master', false)) + .toThrow('CDN_API_HOST is required; external CMS fallbacks are disabled.'); + expect(fetch).not.toHaveBeenCalled(); + }); - const deliveryAgents = createDeliveryClient.mock.calls - .map(call => call[0].httpsAgent); + test('resolves linked compatibility entries without the provider SDK', async () => { + fetch.mockResolvedValue(response({ + items: [{ + fields: { author: { sys: { id: 'author-id', linkType: 'Entry', type: 'Link' } } }, + sys: { id: 'article-id', type: 'Entry' }, + }], + includes: { + Entry: [{ + fields: { name: 'Payload Author' }, + sys: { id: 'author-id', type: 'Entry' }, + }], + }, + limit: 100, + skip: 0, + total: 1, + })); - expect(deliveryAgents.length).toBeGreaterThan(1); - deliveryAgents.forEach((agent) => { - expect(agent).toBe(deliveryAgents[0]); - expect(agent.options.keepAlive).toBe(true); + const result = await getService('default', 'master', false).queryEntries({ + 'fields.slug': 'payload%20article', }); - await articleVote({ + expect(result.items[0].fields.author.fields.name).toBe('Payload Author'); + expect(fetch.mock.calls[0][0]).toContain('/entries?fields.slug=payload%20article'); + }); + + test('rejects compatibility responses containing retired asset URLs', async () => { + fetch.mockResolvedValue(response({ + fields: { file: { url: '//images.ctfassets.net/space/asset/file.png' } }, + sys: { id: 'asset-id', type: 'Asset' }, + })); + + await expect(new ApiService('https://cms.topcoder-dev.com/spaces/a/environments/master', 'key') + .getAsset('asset-id')).rejects.toThrow('retired provider URL'); + }); + + test('writes votes only through the configured Payload endpoint', async () => { + fetch.mockResolvedValue(response({ updated: true })); + + await expect(articleVote({ id: 'article-id', votes: { downvotes: 1, upvotes: 2 }, + }, 'EDU', 'master')).resolves.toEqual({ updated: true }); + + expect(fetch.mock.calls[0][0]) + .toBe('https://cms.topcoder-dev.com/contentful-management/votes'); + expect(fetch.mock.calls[0][1]).toMatchObject({ + method: 'POST', + redirect: 'manual', + body: JSON.stringify({ + spaceId: 'edu-space', + environment: 'master', + entryId: 'article-id', + votes: { downvotes: 1, upvotes: 2 }, + }), }); + }); - const managementConfig = contentfulManagement.createClient.mock.calls[0][0]; - expect(managementConfig.httpsAgent).toBe(deliveryAgents[0]); + test('does not fall back when vote write-through is unconfigured', async () => { + const originalUrl = config.SECRET.CONTENTFUL.PAYLOAD_VOTE_API_URL; + config.SECRET.CONTENTFUL.PAYLOAD_VOTE_API_URL = ''; + await expect(articleVote({ id: 'article-id', votes: {} }, 'EDU', 'master')) + .rejects.toThrow('external CMS fallbacks are disabled'); + config.SECRET.CONTENTFUL.PAYLOAD_VOTE_API_URL = originalUrl; + expect(fetch).not.toHaveBeenCalled(); }); }); diff --git a/__tests__/shared/routes/TopcoderRoutes.jsx b/__tests__/shared/routes/TopcoderRoutes.jsx index 64e639cd4..9e1ff88cc 100644 --- a/__tests__/shared/routes/TopcoderRoutes.jsx +++ b/__tests__/shared/routes/TopcoderRoutes.jsx @@ -1,9 +1,15 @@ import React from 'react'; import Renderer from 'react-test-renderer/shallow'; -import { Route, Switch, matchPath } from 'react-router-dom'; +import { + Redirect, + Route, + Switch, + matchPath, +} from 'react-router-dom'; import { config } from 'topcoder-react-utils'; import ContentfulRoute from 'components/Contentful/Route'; +import Error404 from 'components/Error404'; import Footer from 'components/TopcoderFooter'; import Header from 'containers/TopcoderHeader'; import EDUHome from 'routes/EDUHome'; @@ -11,7 +17,7 @@ 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', () => { +test('matches app-owned and Thrive routes before the generic root CMS route', () => { const renderer = new Renderer(); renderer.render(); @@ -61,4 +67,26 @@ test('matches exact Thrive routes before the generic root Contentful route', () expect(route.props.component).toBe(expectedRoute.component); } }); + + [ + '/challenges/terms/detail/:termId', + '/challenges', + '/engagements', + '/notifications', + '/home', + '/changelog/', + ].forEach((path) => { + const routeIndex = routes.findIndex(route => ( + route.type === Route && route.props.path === path + )); + expect(routeIndex).toBeGreaterThan(-1); + expect(routeIndex).toBeLessThan(contentfulRouteIndex); + }); + + const dashboardRedirectIndex = routes.findIndex(route => ( + route.type === Redirect && route.props.from === '/my-dashboard' + )); + expect(dashboardRedirectIndex).toBeGreaterThan(-1); + expect(dashboardRedirectIndex).toBeLessThan(contentfulRouteIndex); + expect(routes[contentfulRouteIndex].props.error404.type).toBe(Error404); }); diff --git a/build.sh b/build.sh index a4cd12681..9b4ef432d 100755 --- a/build.sh +++ b/build.sh @@ -21,7 +21,6 @@ docker build -t $TAG \ --build-arg CONTENTFUL_TOPGEAR_CDN_API_KEY=$CONTENTFUL_TOPGEAR_CDN_API_KEY \ --build-arg CONTENTFUL_TOPGEAR_PREVIEW_API_KEY=$CONTENTFUL_TOPGEAR_PREVIEW_API_KEY \ --build-arg CONTENTFUL_TOPGEAR_SPACE_ID=$CONTENTFUL_TOPGEAR_SPACE_ID \ - --build-arg CONTENTFUL_MANAGEMENT_TOKEN=$CONTENTFUL_MANAGEMENT_TOKEN \ --build-arg CONTENTFUL_EDU_SPACE_ID=$CONTENTFUL_EDU_SPACE_ID \ --build-arg CONTENTFUL_EDU_CDN_API_KEY=$CONTENTFUL_EDU_CDN_API_KEY \ --build-arg CONTENTFUL_EDU_PREVIEW_API_KEY=$CONTENTFUL_EDU_PREVIEW_API_KEY \ diff --git a/config/custom-environment-variables.js b/config/custom-environment-variables.js index 75a08431a..ab31a04bd 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_APP: 'PAYLOAD_CMS_URL', CMS_ASSETS: 'PAYLOAD_CMS_ASSET_URL', COMMUNITY_APP: 'COMMUNITY_APP_URL', EMAIL_VERIFY_URL: 'EMAIL_VERIFY_URL', @@ -37,7 +38,6 @@ 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: { diff --git a/config/default.js b/config/default.js index c4d59237f..d3b66cfd6 100644 --- a/config/default.js +++ b/config/default.js @@ -111,6 +111,8 @@ 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', + /* Payload CMS application and S3-backed asset origin. */ + CMS_APP: 'https://cms.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', @@ -209,9 +211,7 @@ module.exports = { CONTENTFUL: { 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. */ + /* Required Payload write-through configuration for article votes. */ PAYLOAD_VOTE_API_URL: '', PAYLOAD_MANAGEMENT_API_KEY: '', default: { // Human-readable name of space diff --git a/config/production.js b/config/production.js index cc73f2c62..063d1cfc9 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_APP: 'https://cms.topcoder.com', CMS_ASSETS: 'https://assets.topcoder.com', CHALLENGES_URL: 'https://www.topcoder.com/challenges', COPILOTS_URL: 'https://copilots.topcoder.com', diff --git a/config/webpack/default.js b/config/webpack/default.js index da01cd13e..3bc8822d3 100644 --- a/config/webpack/default.js +++ b/config/webpack/default.js @@ -21,6 +21,17 @@ module.exports = { tls: 'empty', net: 'empty', }, + resolve: { + alias: { + /* The isomorphic service loads this module only during Node SSR. Replace + * it in browser bundles so server credentials and HTTP clients cannot + * become client assets. */ + 'server/services/contentful$': path.resolve( + __dirname, + '../../src/client/shims/contentful-service.js', + ), + }, + }, module: { noParse: [ diff --git a/docs/contentful-configuration.md b/docs/contentful-configuration.md index 0c09756c8..6e8254922 100644 --- a/docs/contentful-configuration.md +++ b/docs/contentful-configuration.md @@ -1,4 +1,9 @@ -# Integrate Hall of Fame with Contentful +# Historical Contentful model setup (migration reference only) + +> This document describes the retired provider-side model bootstrap workflow. +> It is retained only as migration history and must not be used for live runtime +> configuration. See [Payload CMS compatibility environment setup](contentful/environment-setup.md) +> for the supported application configuration. ## Contentful diff --git a/docs/contentful/environment-setup.md b/docs/contentful/environment-setup.md index 2582ca910..403377724 100644 --- a/docs/contentful/environment-setup.md +++ b/docs/contentful/environment-setup.md @@ -1,81 +1,59 @@ -# Environment Setup +# Payload CMS compatibility environment setup -It is not feasible to have a common Contentful environment for development. You -should register your own free Contentful account, and use it for development and -testing. To facilitate review of your solution, provide reviewers with access to -your Contentful space. +Community App retains Contentful-shaped route, schema, and environment-variable +names while content models are migrated. These names are compatibility +contracts only: the running application does not fall back to Contentful APIs +or asset hosts. -To sync with the current config of Topcoder's Contentful account, install -[Contentful CLI](https://www.npmjs.com/package/contentful-cli): -```bash -$ npm install -g contentful-cli contentful-migration-cli -$ contentful login -``` - -Then download and import -[the TC core](https://github.com/topcoder-platform/community-app/blob/develop/config/contentful/tc-core.json) file which will create all core content types used by Topcoder integration: -```bash -$ contentful space import --space-id --content-file --content-model-only -``` - -To run Community App locally against your Contentful account: -1. In Contentful web-interface, generate API keys for - [content delivery](https://www.contentful.com/developers/docs/references/content-delivery-api/) - and [preview](https://www.contentful.com/developers/docs/references/content-preview-api/) APIs. -2. On your system you should provide them to Community App via environment - variables. The most convenient way is to create a setup file like this: - ```bash - #!/bin/bash - export CONTENTFUL_CDN_API_KEY="" - export CONTENTFUL_LOCAL_MODE=1 - export CONTENTFUL_PREVIEW_API_KEY="" - export CONTENTFUL_SPACE_ID="" - ``` - Then, before running Community App from a new console, source it (provided - you have named it `set-contentful-env.sh`), and then run the app: - ```bash - $ 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). - -### 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. +For each supported space, configure its legacy space identifier, Delivery and +Preview bearer keys, and the Payload compatibility host. Hosts must be Topcoder +Payload hostnames without a path. Missing hosts fail closed before an outbound +request is made. ```bash # Default space +export CONTENTFUL_SPACE_ID="" +export CONTENTFUL_CDN_API_KEY="" +export CONTENTFUL_PREVIEW_API_KEY="" export CONTENTFUL_CDN_API_HOST="cms.topcoder-dev.com" export CONTENTFUL_PREVIEW_API_HOST="cms.topcoder-dev.com" # EDU space +export CONTENTFUL_EDU_SPACE_ID="" +export CONTENTFUL_EDU_CDN_API_KEY="" +export CONTENTFUL_EDU_PREVIEW_API_KEY="" 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_SPACE_ID="" +export CONTENTFUL_TOPGEAR_CDN_API_KEY="" +export CONTENTFUL_TOPGEAR_PREVIEW_API_KEY="" 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: +Zurich and Comcast have no compatibility host variables in the application +configuration. Requests for those unsupported spaces fail closed; they must be +migrated and explicitly configured before they can serve live content again. + +Configure the Payload application, its S3-backed public asset origin, and the +article-vote write-through endpoint separately: ```bash +export PAYLOAD_CMS_URL="https://cms.topcoder-dev.com" +export PAYLOAD_CMS_ASSET_URL="https://assets.topcoder-dev.com" 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. +The vote URL and credential are required for article voting. There is no +management-API fallback. Compatibility API and vote requests do not follow HTTP +redirects. Asset redirects are accepted only when their destination matches +`PAYLOAD_CMS_ASSET_URL`; compatibility responses containing retired provider +URLs are rejected. -`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. +Use production equivalents (`cms.topcoder.com` and `assets.topcoder.com`) in +production. Run `npm run verify:no-retired-cms-targets` after building to scan +runtime sources, deployment inputs, and generated assets for retired CMS API or +CDN URL targets. diff --git a/package-lock.json b/package-lock.json index 81daca27e..0db907e7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5497,55 +5497,6 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" }, - "contentful": { - "version": "7.15.2", - "resolved": "https://registry.npmjs.org/contentful/-/contentful-7.15.2.tgz", - "integrity": "sha512-hu+hq0mi7mR7TEKdDg+WyId25Oe4lgNi5WsrPKPlCNBKDQ0QOZly8Vyq/9LF2hR4cbn9tTnRWElIU9Q+JNgP7Q==", - "requires": { - "axios": "^0.20.0", - "contentful-resolve-response": "^1.3.0", - "contentful-sdk-core": "^6.5.0", - "fast-copy": "^2.1.0", - "json-stringify-safe": "^5.0.1" - } - }, - "contentful-management": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/contentful-management/-/contentful-management-5.28.0.tgz", - "integrity": "sha512-o+qihN3zrD6+/BT/e8n26jl/zQvmV6+9S6NY5QDmzM+IaiSeCk6yvPMq74s+IZT9mOS54igl6qFTbeIpdJ9FDA==", - "requires": { - "axios": "^0.19.0", - "contentful-sdk-core": "^6.4.0", - "lodash": "^4.17.11", - "type-fest": "0.15.1" - }, - "dependencies": { - "axios": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", - "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", - "requires": { - "follow-redirects": "1.5.10" - } - }, - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "requires": { - "ms": "2.0.0" - } - }, - "follow-redirects": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", - "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", - "requires": { - "debug": "=3.1.0" - } - } - } - }, "contentful-resolve-response": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/contentful-resolve-response/-/contentful-resolve-response-1.6.3.tgz", @@ -5554,18 +5505,6 @@ "fast-copy": "^2.1.7" } }, - "contentful-sdk-core": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/contentful-sdk-core/-/contentful-sdk-core-6.11.0.tgz", - "integrity": "sha512-ukKxiiHdCa/izTQbA3/VUPMQB2PZW5D2KYjV9WQVOc8QjmDhu1wpEDkYxYjOrUDgT5tM7xw6umpwlifxoYe9kQ==", - "requires": { - "fast-copy": "^2.1.0", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "p-throttle": "^4.1.1", - "qs": "^6.9.4" - } - }, "conventional-changelog": { "version": "3.1.25", "resolved": "https://registry.npmjs.org/conventional-changelog/-/conventional-changelog-3.1.25.tgz", diff --git a/package.json b/package.json index 1a55fedcb..146cfe83e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "conventional-changelog": "conventional-changelog", - "build": "npm run clean && ./node_modules/.bin/webpack --env=production --progress --profile --colors --display-optimization-bailout", + "build": "npm run clean && ./node_modules/.bin/webpack --env=production --progress --profile --colors --display-optimization-bailout && npm run verify:no-retired-cms-targets", "build:dev": "npm run clean && ./node_modules/.bin/webpack --env=development --progress --profile --colors --display-optimization-bailout", "build:qa": "npm run clean && ./node_modules/.bin/webpack --env=qa --progress --profile --colors --display-optimization-bailout", "clean": "rimraf build", @@ -16,7 +16,8 @@ "lint:scss": "stylelint **/*.scss --syntax scss", "update-tests": "npm run jest -- -u", "start": "cross-env BABEL_ENV=production NODE_ENV=production node --max-old-space-size=8192 ./bin/www", - "test": "npm run lint && npm run --runInBand jest", + "test": "npm run lint && npm run verify:no-retired-cms-targets && npm run --runInBand jest", + "verify:no-retired-cms-targets": "node scripts/verify-no-retired-cms-targets.js", "commitlint": "commitlint -E HUSKY_GIT_PARAMS", "release:changelog": "npm run conventional-changelog -- -p angular -i CHANGELOG.md -s", "postinstall": "rimraf node_modules/navigation-component/node_modules/topcoder-react-utils && rimraf node_modules/topcoder-react-ui-kit/node_modules/topcoder-react-utils" @@ -49,8 +50,7 @@ "browser-cookies": "^1.2.0", "classnames": "^2.2.6", "config": "^1.30.0", - "contentful": "^7.14.2", - "contentful-management": "^5.10.0", + "contentful-resolve-response": "1.6.3", "cookie-parser": "^1.4.3", "cors": "^2.8.5", "country-list": "^2.1.1", @@ -78,6 +78,7 @@ "i18n-iso-countries": "^3.7.1", "immutable": "^3.8.2", "isomorphic-fetch": "^2.2.1", + "json-stringify-safe": "5.0.1", "joi": "^17.7.1", "js-beautify": "^1.10.3", "le_node": "^1.7.0", diff --git a/scripts/verify-no-retired-cms-targets.js b/scripts/verify-no-retired-cms-targets.js new file mode 100644 index 000000000..582f98cd4 --- /dev/null +++ b/scripts/verify-no-retired-cms-targets.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node + +/* Verifies that runtime, build, and deploy inputs contain no URL whose network + * host is a retired CMS API or asset provider. Contentful-shaped schema names + * and migrated S3 object paths are intentionally allowed. */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const TARGETS = [ + '.circleci', + 'Dockerfile', + 'build', + 'build.sh', + 'config/custom-environment-variables.js', + 'config/default.js', + 'config/development.js', + 'config/production.js', + 'config/qa.js', + 'package.json', + 'src', +]; +const RETIRED_HOSTS = /(?:https?:)?\/\/(?:[^\s/"'<>]*\.)?(?:contentful\.com|ctfassets\.net|netlify\.app|netlify\.com|netlifyusercontent\.com|octana\.io)(?=[:/\s"'<>]|$)/gi; +const RETIRED_API_HOST_LITERALS = /\b(?:api|app|cdn|preview)\.contentful\.com\b/gi; + +function getFiles(target) { + if (!fs.existsSync(target)) return []; + const stat = fs.statSync(target); + if (stat.isFile()) return [target]; + return fs.readdirSync(target) + .reduce((files, name) => files.concat(getFiles(path.join(target, name))), []); +} + +const findings = []; +TARGETS.forEach((target) => { + getFiles(path.join(ROOT, target)).forEach((file) => { + const stat = fs.statSync(file); + if (stat.size > 50 * 1024 * 1024) return; + let source; + try { + source = fs.readFileSync(file, 'utf8').replace(/\\\//g, '/'); + } catch (error) { + return; + } + source.split(/\r?\n/).forEach((line, index) => { + RETIRED_HOSTS.lastIndex = 0; + RETIRED_API_HOST_LITERALS.lastIndex = 0; + const matches = (line.match(RETIRED_HOSTS) || []) + .concat(line.match(RETIRED_API_HOST_LITERALS) || []); + if (matches.length) { + findings.push(`${path.relative(ROOT, file)}:${index + 1}: ${matches.join(', ')}`); + } + }); + }); +}); + +if (findings.length) { + process.stderr.write(`Retired CMS network targets detected:\n${findings.join('\n')}\n`); + process.exitCode = 1; +} else { + process.stdout.write('No retired CMS network targets detected.\n'); +} diff --git a/src/client/shims/contentful-service.js b/src/client/shims/contentful-service.js new file mode 100644 index 000000000..4a6db69ba --- /dev/null +++ b/src/client/shims/contentful-service.js @@ -0,0 +1,10 @@ +/** + * Browser-only replacement for the server Payload compatibility service. + * Client-side CMS requests use Community App's same-origin proxy instead. + */ + +export function getService() { + throw new Error('The server CMS compatibility service is unavailable in the browser.'); +} + +export default { getService }; diff --git a/src/server/index.js b/src/server/index.js index e39008755..4a1c4546e 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -29,6 +29,7 @@ import mockDocuSignFactory from './__mocks__/docu-sign-mock'; import recruitCRMRouter from './routes/recruitCRM'; import mmLeaderboardRouter from './routes/mmLeaderboard'; import feedsRouter from './routes/feeds'; +import { getPayloadAppUrl } from './services/cms-urls'; /* Dome API for topcoder communities */ import tcCommunitiesDemoApi from './tc-communities'; @@ -38,7 +39,7 @@ import webpackConfigFactory from '../../webpack.config'; global.atob = atob; -const CMS_BASE_URL = `https://app.contentful.com/spaces/${config.SECRET.CONTENTFUL.SPACE_ID}`; +const CMS_BASE_URL = getPayloadAppUrl(); const getTimestamp = async () => { let timestamp; @@ -239,7 +240,6 @@ async function onExpressJsSetup(server) { + ' https://d24oibycet9bsb.cloudfront.net' + ' https://d2nl5eqipnb33q.cloudfront.net' + ` ${config.URL.CMS_ASSETS}` - + ' https://images.ctfassets.net' + ' https://heapanalytics.com' + ' https://q.quora.com' + ' https://topcoder-prod-media.s3.amazonaws.com' @@ -249,7 +249,6 @@ async function onExpressJsSetup(server) { + ' https://www.google.com' + ' https://www.googletagmanager.com' + ' https://i.ytimg.com' - + ' https://images.contentful.com' + ' https://member-media.topcoder-dev.com' + ' https://member-media.topcoder.com' + ' https://track.hubspot.com' @@ -263,7 +262,7 @@ async function onExpressJsSetup(server) { if (req.url.startsWith('/examples')) { // eslint-disable-next-line quotes - res.header('Content-Security-Policy', `frame-ancestors 'self' https://app.contentful.com`); + res.header('Content-Security-Policy', `frame-ancestors 'self' ${CMS_BASE_URL}`); res.removeHeader('X-Frame-Options'); } @@ -298,7 +297,7 @@ async function onExpressJsSetup(server) { server.use( '/community-app-assets/api/edit-contentful-entry/:id', - (req, res) => res.redirect(`${CMS_BASE_URL}/entries/${req.params.id}`), + (req, res) => res.redirect(`${CMS_BASE_URL}/admin?legacyEntryId=${encodeURIComponent(req.params.id)}`), ); /** diff --git a/src/server/routes/contentful.js b/src/server/routes/contentful.js index 8189c525c..3079bfcee 100644 --- a/src/server/routes/contentful.js +++ b/src/server/routes/contentful.js @@ -7,13 +7,10 @@ import { middleware } from 'tc-core-library-js'; import config from 'config'; import _ from 'lodash'; import { - ASSETS_DOMAIN, - IMAGES_DOMAIN, getService, - getSpaceId, articleVote, - ALLOWED_DOMAINS, } from '../services/contentful'; +import { getPayloadAssetUrl } from '../services/cms-urls'; const cors = require('cors'); @@ -26,53 +23,22 @@ const routes = express.Router(); routes.use(cors()); routes.options('*', cors()); -/* Gets non-image asset file. */ -routes.use( - '/:spaceName/:environment/assets/:id/:version/:name', - (req, res, next) => { - try { - const { - environment, - id, - name, - spaceName, - version, - } = req.params; - const spaceId = getSpaceId(spaceName); - if (!ALLOWED_DOMAINS.includes(ASSETS_DOMAIN)) { - throw new Error('Invalid domain detected!'); - } - const url = new URL(`https://${ASSETS_DOMAIN}/spaces/${spaceId}/environments/${environment}/${id}/${version}/${name}`); - res.redirect(url.href); - } catch (e) { - next(e); - } - }, -); +async function redirectPayloadAsset(req, res, next) { + try { + const { environment, id, spaceName } = req.params; + const asset = await getService(spaceName, environment, false).getAsset(id); + const url = getPayloadAssetUrl(_.get(asset, 'fields.file.url')); + res.redirect(url); + } catch (error) { + next(error); + } +} -/* Gets image file. */ -routes.use( - '/:spaceName/:environment/images/:id/:version/:name', - (req, res, next) => { - try { - const { - environment, - id, - name, - spaceName, - version, - } = req.params; - if (!ALLOWED_DOMAINS.includes(IMAGES_DOMAIN)) { - throw new Error('Invalid domain detected!'); - } - const spaceId = getSpaceId(spaceName); - const url = new URL(`https://${IMAGES_DOMAIN}/spaces/${spaceId}/environments/${environment}/${id}/${version}/${name}`); - res.redirect(url.href); - } catch (e) { - next(e); - } - }, -); +/* Legacy asset URLs resolve through Payload and may redirect only to the + * configured S3-backed asset origin. Version and name remain for old links but + * are never trusted as a destination. */ +routes.use('/:spaceName/:environment/assets/:id/:version/:name', redirectPayloadAsset); +routes.use('/:spaceName/:environment/images/:id/:version/:name', redirectPayloadAsset); /* Gets preview of the specified space_name, environment & asset. */ routes.use('/:spaceName/:environment/preview/assets/:id', (req, res, next) => { @@ -122,7 +88,7 @@ routes.use( ); /* Queries published assets of a given space name & environment. */ -routes.use(':spaceName/:environment/published/assets', (req, res, next) => { +routes.use('/:spaceName/:environment/published/assets', (req, res, next) => { try { const { environment, spaceName } = req.params; getService(spaceName, environment, false) @@ -163,7 +129,7 @@ routes.use('/:spaceName/:environment/published/entries', (req, res, next) => { /* Update votes on article. */ routes.use('/:spaceName/:environment/votes', (req, res, next) => authenticator(authenticatorOptions)(req, res, next), (req, res, next) => { try { - articleVote(req.body) + articleVote(req.body, req.params.spaceName, req.params.environment) .then(res.send.bind(res), next); } catch (e) { next(e); diff --git a/src/server/services/cms-urls.js b/src/server/services/cms-urls.js new file mode 100644 index 000000000..3c77a8168 --- /dev/null +++ b/src/server/services/cms-urls.js @@ -0,0 +1,121 @@ +/** + * Runtime URL guards for Payload CMS responses and redirects. + */ + +import config from 'config'; + +const RETIRED_PROVIDER_HOST_SUFFIXES = [ + 'contentful.com', + 'ctfassets.net', + 'netlify.app', + 'netlify.com', + 'netlifyusercontent.com', + 'octana.io', +]; +const URL_PATTERN = /(?:https?:)?\/\/[^\s<>"')\]]+/gi; + +function isRetiredProviderHostname(hostname) { + const normalized = hostname.toLowerCase(); + return RETIRED_PROVIDER_HOST_SUFFIXES.some(suffix => ( + normalized === suffix || normalized.endsWith(`.${suffix}`) + )); +} + +function parseNetworkUrl(value, baseUrl) { + if (value.startsWith('//')) return new URL(`https:${value}`); + return new URL(value, baseUrl); +} + +function isTopcoderOrLocalHostname(hostname) { + return hostname === 'localhost' + || hostname === '127.0.0.1' + || hostname.endsWith('.topcoder.com') + || hostname.endsWith('.topcoder-dev.com'); +} + +/** + * Returns the validated Payload application origin used by editor redirects + * and CSP. This prevents PAYLOAD_CMS_URL from reintroducing an external host. + * + * @return {String} Payload application origin. + */ +export function getPayloadAppUrl() { + const parsed = new URL(config.URL.CMS_APP); + if (!isTopcoderOrLocalHostname(parsed.hostname)) { + throw new Error('PAYLOAD_CMS_URL must target an approved Topcoder Payload CMS host.'); + } + if (parsed.protocol !== 'https:' + && !['127.0.0.1', 'localhost'].includes(parsed.hostname)) { + throw new Error('PAYLOAD_CMS_URL must use HTTPS.'); + } + return parsed.origin; +} + +/** + * Rejects provider URLs embedded anywhere in a compatibility API response. + * This covers legacy URLs inside rich text/markdown as well as Asset records. + * + * @param {*} value Response value to inspect. + * @return {*} The original value, for convenient inline validation. + */ +export function assertNoRetiredCmsUrls(value) { + const visited = new WeakSet(); + + function inspect(current) { + if (typeof current === 'string') { + const matches = current.match(URL_PATTERN) || []; + matches.forEach((match) => { + let parsed; + try { + parsed = parseNetworkUrl(match, 'https://localhost'); + } catch (error) { + return; + } + if (isRetiredProviderHostname(parsed.hostname)) { + throw new Error('Payload CMS response contains a retired provider URL.'); + } + }); + return; + } + if (!current || typeof current !== 'object' || visited.has(current)) return; + visited.add(current); + Object.keys(current).forEach(key => inspect(current[key])); + } + + inspect(value); + return value; +} + +/** + * Converts an Asset URL to an absolute URL and proves it points at the + * configured S3-backed asset origin before an HTTP redirect is emitted. + * + * @param {String} assetUrl URL returned by Payload's compatibility API. + * @return {String} Validated absolute asset URL. + */ +export function getPayloadAssetUrl(assetUrl) { + if (typeof assetUrl !== 'string' || !assetUrl) { + throw new TypeError('Payload CMS Asset response does not contain a file URL.'); + } + + const configuredBase = new URL(config.URL.CMS_ASSETS); + if (isRetiredProviderHostname(configuredBase.hostname)) { + throw new Error('PAYLOAD_CMS_ASSET_URL cannot target the retired CMS provider.'); + } + if (configuredBase.protocol !== 'https:' + && !['127.0.0.1', 'localhost'].includes(configuredBase.hostname)) { + throw new Error('PAYLOAD_CMS_ASSET_URL must use HTTPS.'); + } + + assertNoRetiredCmsUrls(assetUrl); + const parsed = parseNetworkUrl(assetUrl, configuredBase); + if (parsed.origin !== configuredBase.origin) { + throw new Error('Payload CMS Asset URL is outside PAYLOAD_CMS_ASSET_URL.'); + } + if (configuredBase.pathname !== '/' + && !parsed.pathname.startsWith(configuredBase.pathname)) { + throw new Error('Payload CMS Asset URL is outside the configured asset path.'); + } + + return parsed.href; +} diff --git a/src/server/services/contentful-endpoints.js b/src/server/services/contentful-endpoints.js index 00697aea2..77ae6abb3 100644 --- a/src/server/services/contentful-endpoints.js +++ b/src/server/services/contentful-endpoints.js @@ -1,47 +1,71 @@ /** - * 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. + * Endpoint helpers for the Payload CMS compatibility API. Environment variable + * names remain Contentful-shaped so existing SSM appvars can be reused during + * the migration, but no provider endpoint is used as a fallback. */ -const CONTENTFUL_CDN_API_HOST = 'cdn.contentful.com'; -const CONTENTFUL_PREVIEW_API_HOST = 'preview.contentful.com'; +const ALLOWED_API_HOST_SUFFIXES = [ + '.topcoder.com', + '.topcoder-dev.com', +]; +const ALLOWED_LOCAL_API_HOSTS = [ + '127.0.0.1', + 'localhost', +]; -/** - * 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]; +function isAllowedApiHostname(hostname) { + const normalized = hostname.toLowerCase(); + return ALLOWED_LOCAL_API_HOSTS.includes(normalized) + || ALLOWED_API_HOST_SUFFIXES.some(suffix => normalized.endsWith(suffix)); +} - if (configuredHost === undefined || configuredHost === null || configuredHost === '') { - return fallback; +function normalizeApiHost(value, property) { + if (value === undefined || value === null || value === '') { + throw new Error(`${property} is required; external CMS fallbacks are disabled.`); } - if (typeof configuredHost !== 'string') { + if (typeof value !== 'string') { throw new TypeError(`${property} must be a hostname string.`); } - return configuredHost.replace(/^https?:\/\//i, '').replace(/\/+$/, ''); + let parsed; + try { + parsed = new URL(/^https?:\/\//i.test(value) ? value : `https://${value}`); + } catch (error) { + throw new TypeError(`${property} must be a valid hostname string.`); + } + + if (parsed.username || parsed.password || parsed.search || parsed.hash + || (parsed.pathname && parsed.pathname !== '/')) { + throw new TypeError(`${property} must not include credentials, a path, query, or fragment.`); + } + if (!isAllowedApiHostname(parsed.hostname)) { + throw new Error(`${property} must target an approved Topcoder Payload CMS host.`); + } + + return parsed.host.toLowerCase(); +} + +/** + * Resolves the compatibility API hostname for one space environment. + * + * @param {Object} environmentConfig The environment's API configuration. + * @param {Boolean} preview Whether the caller needs the Preview API host. + * @return {String} A validated hostname. + */ +export function getContentfulApiHost(environmentConfig = {}, preview) { + const property = preview ? 'PREVIEW_API_HOST' : 'CDN_API_HOST'; + return normalizeApiHost(environmentConfig[property], property); } /** - * Builds a Contentful-compatible API base URL for direct HTTP requests. + * Builds a Payload compatibility API base URL. * * @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. + * @param {String} spaceId Legacy space identifier used by the compatibility API. + * @param {String} environment Legacy environment name used by the compatibility API. + * @return {String} The HTTPS base URL used by ApiService. */ export function getContentfulApiBaseUrl(host, spaceId, environment) { - return `https://${host}/spaces/${encodeURIComponent(spaceId)}/environments/${encodeURIComponent(environment)}`; + const safeHost = normalizeApiHost(host, 'CMS API host'); + return `https://${safeHost}/spaces/${encodeURIComponent(spaceId)}/environments/${encodeURIComponent(environment)}`; } diff --git a/src/server/services/contentful.js b/src/server/services/contentful.js index 07dacd3ac..af13c8ba1 100644 --- a/src/server/services/contentful.js +++ b/src/server/services/contentful.js @@ -1,289 +1,186 @@ /** - * Server-side functions necessary for effective integration - * with Contentful CMS + * Server-side Payload CMS compatibility services. + * + * Contentful-shaped route and configuration names are intentionally retained + * while callers migrate, but every outbound request is sent only to an + * explicitly configured Topcoder Payload host. */ import _ from 'lodash'; import config from 'config'; -import { createClient } from 'contentful'; +import resolveResponse from 'contentful-resolve-response'; 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 stringifySafe from 'json-stringify-safe'; +import qs from 'qs'; import { getContentfulApiBaseUrl, getContentfulApiHost, } from './contentful-endpoints'; +import { assertNoRetiredCmsUrls } from './cms-urls'; -const contentful = require('contentful-management'); - -/** - * 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'; - -export const ALLOWED_DOMAINS = [ASSETS_DOMAIN, IMAGES_DOMAIN]; +const cmsHttpsAgent = new https.Agent({ keepAlive: true }); const MAX_FETCH_RETRIES = 5; -/** - * Generic logger for errors and warnings - * from Contentful API calls - * @param {String} level - * @param {String} data - */ -function logHandler(level, data) { - if (isomorphy.isDev) { - logger.log('Contentful logHandler', level, data); - } -} - -/** - * Creates a promise that resolves two second after its creation. - * @return {Promise} - */ function threeSecondDelay() { return new Promise(resolve => setTimeout(resolve, 3000)); } -/** - * Auxiliary class that handles communication with Contentful CDN and preview - * APIs in the same uniform manner. - */ -class ApiService { - /** - * Creates a new service instance. - * @param {String} baseUrl The base API endpoint. - * @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, host) { - this.private = { - baseUrl, key, spaceId, preview, host, - }; - // client config - const clientConf = { - accessToken: key, - httpsAgent: contentfulHttpsAgent, - space: spaceId, - logHandler, - host, - }; - // create the client to work with - this.client = createClient(clientConf); +function decodeQuery(value) { + if (_.isArray(value)) return value.map(decodeQuery); + if (_.isPlainObject(value)) return _.mapValues(value, decodeQuery); + return typeof value === 'string' ? decodeURIComponent(value) : value; +} + +function toSerializableEntryCollection(data) { + const collection = { + ...data, + items: resolveResponse(data, { itemEntryPoints: ['fields'] }), + }; + return JSON.parse(stringifySafe(collection, null, 0, (key, value) => ({ + sys: { + circular: true, + id: _.get(value, 'sys.id'), + linkType: 'Entry', + type: 'Link', + }, + }))); +} + +/** HTTP client for Payload's Contentful-compatible API routes. */ +export class ApiService { + constructor(baseUrl, key) { + this.private = { baseUrl, key }; } - /** - * Gets data from the specified endpoing. - * @param {String} endpoint - * @param {Object} query Optional. URL query to append to the request. - * @return {Promise} - */ async fetch(endpoint, query) { let url = `${this.private.baseUrl}${endpoint}`; if (query) url += `?${qs.stringify(query)}`; let res; for (let i = 0; i < MAX_FETCH_RETRIES; i += 1) { - /* The loop is here to retry async operation multiple times in case of - * failures due to violation of Contentful API rate limits, which are - * 78 requests within 1 second. Thus, it is a valid use of await inside - * loop. */ /* eslint-disable no-await-in-loop */ res = await fetch(url, { + agent: cmsHttpsAgent, headers: { Authorization: `Bearer ${this.private.key}` }, + redirect: 'manual', }); - /* 429 = "Too Many Requests" */ if (res.status !== 429) break; await threeSecondDelay(); /* eslint-enable no-await-in-loop */ } - if (!res.ok) throw new Error(res.statusText); - return res.json(); + if (!res.ok) { + throw new Error(`Payload CMS compatibility request failed with status ${res.status}.`); + } + const data = await res.json(); + return assertNoRetiredCmsUrls(data); } - /** - * Gets the specified asset. - * @param {String} id Asset ID. - * @return {Promise} - */ async getAsset(id) { - const res = await this.client.getAsset(id); - return res.stringifySafe ? JSON.parse(res.stringifySafe()) : res; + return this.fetch(`/assets/${encodeURIComponent(id)}`); } - /** - * Gets the specified content entry. - * @param {String} id Entry ID. - * @return {Promise} - */ async getEntry(id) { - const res = await this.client.getEntry(id); - return res.stringifySafe ? JSON.parse(res.stringifySafe()) : res; + return this.fetch(`/entries/${encodeURIComponent(id)}`); } - /** - * Queries assets. - * @param {Object} query Optional. Query. - * @return {Promise} - */ async queryAssets(query) { - const res = await this.client.getAssets(query); - return res.stringifySafe ? JSON.parse(res.stringifySafe()) : res; + return this.fetch('/assets', query); } - /** - * Gets an array of content entries. - * @param {Object} query Optional. Query for filtering / sorting of entries. - * @return {Promise} - */ async queryEntries(query) { - const decode = o => _.mapValues(o, prop => (typeof prop === 'object' ? decode(prop) : decodeURIComponent(prop))); - const decoded = decode(query); - const res = await this.client.getEntries(decoded); - return res.stringifySafe ? JSON.parse(res.stringifySafe()) : res; + const data = await this.fetch('/entries', decodeQuery(query)); + return toSerializableEntryCollection(data); } } /** - * Updates votes count in Contentful articles - * @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. + * Writes article votes through Payload. There is deliberately no management + * API fallback: incomplete configuration fails before any network request. */ -export function articleVote(body) { +export function articleVote(body, spaceName = 'EDU', environment = 'master') { 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 apiKey = config.SECRET.CONTENTFUL.PAYLOAD_MANAGEMENT_API_KEY; + if (!payloadUrl) { + return Promise.reject(new Error('CONTENTFUL_PAYLOAD_VOTE_API_URL is required; external CMS fallbacks are disabled.')); + } + if (!apiKey) { + return Promise.reject(new Error('CONTENTFUL_PAYLOAD_MANAGEMENT_API_KEY is required for article voting.')); } - 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')) - .then(environment => environment.getEntry(body.id)) - .then((entry) => { - if (!entry.fields.upvotes) { - // eslint-disable-next-line no-param-reassign - entry.fields.upvotes = { - 'en-US': body.votes.upvotes, - }; - } else { - // eslint-disable-next-line no-param-reassign - entry.fields.upvotes['en-US'] = body.votes.upvotes; - } - if (!entry.fields.downvotes) { - // eslint-disable-next-line no-param-reassign - entry.fields.downvotes = { - 'en-US': body.votes.downvotes, - }; - } else { - // eslint-disable-next-line no-param-reassign - entry.fields.downvotes['en-US'] = body.votes.downvotes; - } - return entry.update(); - }) - .then(entry => entry.publish()); -} - -let services; + let parsedUrl; + try { + parsedUrl = new URL(payloadUrl); + } catch (error) { + return Promise.reject(new TypeError('CONTENTFUL_PAYLOAD_VOTE_API_URL must be a valid URL.')); + } + const allowedHost = parsedUrl.hostname === 'localhost' + || parsedUrl.hostname === '127.0.0.1' + || parsedUrl.hostname.endsWith('.topcoder.com') + || parsedUrl.hostname.endsWith('.topcoder-dev.com'); + if (parsedUrl.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(parsedUrl.hostname)) { + return Promise.reject(new Error('CONTENTFUL_PAYLOAD_VOTE_API_URL must use HTTPS.')); + } + if (!allowedHost) { + return Promise.reject(new Error('CONTENTFUL_PAYLOAD_VOTE_API_URL must target an approved Topcoder Payload CMS host.')); + } -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) => { - services[spaceName] = {}; - _.map(spaceConfig, (env, name) => { - if (name !== 'SPACE_ID') { - const environment = name; - const spaceId = spaceConfig.SPACE_ID; - const previewHost = getContentfulApiHost(env, true); - const cdnHost = getContentfulApiHost(env, false); - const previewBaseUrl = getContentfulApiBaseUrl(previewHost, spaceId, environment); - const cdnBaseUrl = getContentfulApiBaseUrl(cdnHost, spaceId, environment); - const svcs = {}; + const spaceId = _.get(config, `SECRET.CONTENTFUL.${spaceName}.SPACE_ID`); + if (!spaceId) { + return Promise.reject(new Error(`Space '${spaceName}' is not configured for Payload CMS voting.`)); + } - svcs.previewService = new ApiService( - previewBaseUrl, env.PREVIEW_API_KEY, spaceId, true, previewHost, - ); - svcs.cdnService = new ApiService( - cdnBaseUrl, env.CDN_API_KEY, spaceId, false, cdnHost, - ); - services[spaceName][environment] = svcs; - } - }); + return fetch(parsedUrl.href, { + agent: cmsHttpsAgent, + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + redirect: 'manual', + body: JSON.stringify({ + spaceId, + environment, + 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(); }); - return services; } -/** - * get space id for the given space name. - * @param {String} spaceName - */ -export function getSpaceId(spaceName) { - const name = spaceName || config.CONTENTFUL.DEFAULT_SPACE_NAME; - return _.get(config, `SECRET.CONTENTFUL.${name}.SPACE_ID`); -} +const services = {}; /** - * exports Contentful CDN/Preview services. - * @param {String} spaceName - * @param {String} environment - * @param {Boolean} preview + * Returns a lazily-created compatibility service. Unsupported legacy spaces + * with no Payload host fail only when requested and never use a default host. */ export function getService(spaceName, environment, preview) { - if (!services) { - services = initServiceInstances(); - } const name = spaceName || config.CONTENTFUL.DEFAULT_SPACE_NAME; - const env = environment || config.CONTENTFUL.DEFAULT_ENVIRONMENT; + const envName = environment || config.CONTENTFUL.DEFAULT_ENVIRONMENT; + const environmentConfig = _.get(config, `SECRET.CONTENTFUL.${name}.${envName}`); + const spaceId = _.get(config, `SECRET.CONTENTFUL.${name}.SPACE_ID`); - if (!services[name]) { - throw new Error(`space : '${name}' is not configured.`); + if (!environmentConfig || !spaceId) { + throw new Error(`Space '${name}' environment '${envName}' is not configured.`); } - if (!services[name][env]) { - throw new Error(`environment : '${env}' is not configured for space : '${name}.`); + + const host = getContentfulApiHost(environmentConfig, preview); + const key = preview + ? environmentConfig.PREVIEW_API_KEY + : environmentConfig.CDN_API_KEY; + if (!key) { + throw new Error(`Space '${name}' environment '${envName}' is missing its Payload compatibility API key.`); } - const service = services[name][env]; - return preview ? service.previewService : service.cdnService; + const cacheKey = `${name}:${envName}:${preview ? 'preview' : 'published'}`; + if (!services[cacheKey]) { + services[cacheKey] = new ApiService( + getContentfulApiBaseUrl(host, spaceId, envName), + key, + ); + } + return services[cacheKey]; } diff --git a/src/shared/components/examples/MemberTalkCloudExample/index.jsx b/src/shared/components/examples/MemberTalkCloudExample/index.jsx index 5deab64e4..bb470ce97 100644 --- a/src/shared/components/examples/MemberTalkCloudExample/index.jsx +++ b/src/shared/components/examples/MemberTalkCloudExample/index.jsx @@ -1,5 +1,6 @@ import React from 'react'; import MemberTalkCloudComponent from 'components/Contentful/MemberTalkCloud'; +import { DEFAULT_AVATAR_URL } from 'utils/url'; import './style.scss'; @@ -10,30 +11,30 @@ export default function MemberTalkCloudExample() { - {/* 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. */} + {/* Application-owned routes must not wait for the root CMS lookup. */} + + + + + + + + + + + + { + config.GAMIFICATION.ENABLE_BADGE_UI && ( + + ) + } + } + /> + + {/* Keep Thrive routes ahead of the generic root CMS route as well. */} - - - - - - - - - - - - { - config.GAMIFICATION.ENABLE_BADGE_UI && ( - - ) - } - } - /> - - - - )} + error404={} id="2z6DvIzyhKQ0YusYGsaQc6" /> diff --git a/src/shared/services/contentful.js b/src/shared/services/contentful.js index 2f1531253..091012311 100644 --- a/src/shared/services/contentful.js +++ b/src/shared/services/contentful.js @@ -217,6 +217,7 @@ class Service { * used above. */ let url = this.private.baseUrl; url += this.private.preview ? '/preview' : '/published'; + url += '/assets'; if (query) url += `?${_.isString(query) ? query : qs.stringify(query)}`; const res = await fetch(url); if (!res.ok) { From 1e74fa1b3806c960513c9337ee30ba67dd3cfffb Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 25 Aug 2026 18:53:14 +1000 Subject: [PATCH 2/3] Harden retired provider response guard --- __tests__/server/cms-urls.js | 63 ++++++++++++++-- __tests__/server/contentful.js | 17 ++++- src/server/services/cms-urls.js | 124 ++++++++++++++++++++++++++------ 3 files changed, 176 insertions(+), 28 deletions(-) diff --git a/__tests__/server/cms-urls.js b/__tests__/server/cms-urls.js index 5cb0ed075..42f993937 100644 --- a/__tests__/server/cms-urls.js +++ b/__tests__/server/cms-urls.js @@ -4,6 +4,17 @@ import { getPayloadAssetUrl, } from 'server/services/cms-urls'; +const RETIRED_HOSTS = [ + ['cdn.', 'content', 'ful.com'].join(''), + ['images.', 'ctf', 'assets.net'].join(''), + ['preview--topcoder.', 'net', 'lify.app'].join(''), + ['deploy-preview.', 'net', 'lify.com'].join(''), + ['assets.', 'net', 'lifyusercontent.com'].join(''), + ['quickedit.', 'oct', 'ana.io'].join(''), +]; +const RETIRED_ASSET_HOST = RETIRED_HOSTS[1]; +const RETIRED_ASSET_URL = `https://${RETIRED_ASSET_HOST}/space/asset/image.png`; + describe('server/services/cms-urls', () => { test('uses the configured Topcoder Payload application origin', () => { expect(getPayloadAppUrl()).toBe('https://cms.topcoder-dev.com'); @@ -17,23 +28,61 @@ describe('server/services/cms-urls', () => { }); test('rejects retired provider URLs in Asset records and rich text', () => { - expect(() => getPayloadAssetUrl('//images.ctfassets.net/space/asset/image.png')) + expect(() => getPayloadAssetUrl(`//${RETIRED_ASSET_HOST}/space/asset/image.png`)) .toThrow('retired provider URL'); expect(() => assertNoRetiredCmsUrls({ - fields: { body: '![old](https://images.ctfassets.net/space/asset/image.png)' }, + fields: { body: `![old](${RETIRED_ASSET_URL})` }, })).toThrow('retired provider URL'); }); + test.each(RETIRED_HOSTS.map(host => `https://${host}/provider-resource`))( + 'rejects direct retired provider URL %s', + (url) => { + expect(() => assertNoRetiredCmsUrls({ fields: { url } })) + .toThrow('retired provider URL'); + }, + ); + + test.each(RETIRED_HOSTS.map(host => `//${host}/provider-resource`))( + 'rejects protocol-relative retired provider URL %s', + (url) => { + expect(() => assertNoRetiredCmsUrls({ fields: { url } })) + .toThrow('retired provider URL'); + }, + ); + test.each([ - 'https://uat--topcoder.netlify.app/image.png', - 'https://quickedit.octana.io/preview', - ])('rejects other retired provider URL %s in Payload responses', (url) => { + `https://player.example.test/embed?source=${RETIRED_ASSET_URL}`, + `https://player.example.test/embed?source=${encodeURIComponent(RETIRED_ASSET_URL)}`, + encodeURIComponent(RETIRED_ASSET_URL), + encodeURIComponent(encodeURIComponent(encodeURIComponent(RETIRED_ASSET_URL))), + `%ZZ&target=${encodeURIComponent(RETIRED_ASSET_URL)}`, + `https:\\/\\/${RETIRED_ASSET_HOST}/json-escaped.png`, + 'https:\\u002f\\u002fimages\\u002ectfassets\\u002enet/unicode-escaped.png', + 'https:\\x2f\\x2fimages\\x2ectfassets\\x2enet/hex-escaped.png', + `https://${RETIRED_ASSET_HOST}/named-entities.png`, + 'https://images.ctfassets.net/numeric-entities.png', + 'https://images%E3%80%82ctfassets%E3%80%82net/unicode-dots.png', + ])('rejects an encoded or nested retired provider reference %s', (url) => { expect(() => assertNoRetiredCmsUrls({ fields: { url } })) .toThrow('retired provider URL'); }); - test('accepts unrelated links in CMS content', () => { - const content = { fields: { contentUrl: 'https://www.topcoder.com/challenges' } }; + test('rejects retired provider URLs used as compatibility response keys', () => { + expect(() => assertNoRetiredCmsUrls({ [RETIRED_ASSET_URL]: 'legacy asset' })) + .toThrow('retired provider URL'); + }); + + test('accepts unrelated links, historical names, and owned media provenance', () => { + const content = { + fields: { + contentUrl: 'https://www.topcoder.com/challenges', + history: 'Contentful, Netlify, and Octana are historical provider names.', + migratedAsset: `https://assets.topcoder-dev.com/media/contentful/${RETIRED_ASSET_HOST}/image.png`, + nestedMediaKey: `https://assets.topcoder-dev.com/media/contentful//${RETIRED_ASSET_HOST}/image.png`, + provenance: `provenance/${RETIRED_ASSET_HOST}/image.png`, + }, + }; expect(assertNoRetiredCmsUrls(content)).toBe(content); }); }); diff --git a/__tests__/server/contentful.js b/__tests__/server/contentful.js index c639d06c4..21dd94f3e 100644 --- a/__tests__/server/contentful.js +++ b/__tests__/server/contentful.js @@ -8,6 +8,9 @@ import { getService, } from 'server/services/contentful'; +const RETIRED_ASSET_HOST = ['images.', 'ctf', 'assets.net'].join(''); +const RETIRED_ASSET_URL = `https://${RETIRED_ASSET_HOST}/space/asset/file.png`; + jest.mock('config', () => ({ CONTENTFUL: { DEFAULT_ENVIRONMENT: 'master', @@ -109,7 +112,7 @@ describe('server/services/contentful Payload compatibility client', () => { test('rejects compatibility responses containing retired asset URLs', async () => { fetch.mockResolvedValue(response({ - fields: { file: { url: '//images.ctfassets.net/space/asset/file.png' } }, + fields: { file: { url: `//${RETIRED_ASSET_HOST}/space/asset/file.png` } }, sys: { id: 'asset-id', type: 'Asset' }, })); @@ -117,6 +120,18 @@ describe('server/services/contentful Payload compatibility client', () => { .getAsset('asset-id')).rejects.toThrow('retired provider URL'); }); + test.each([ + `https://player.example.test/embed?source=${RETIRED_ASSET_URL}`, + encodeURIComponent(encodeURIComponent(RETIRED_ASSET_URL)), + 'https:\\u002f\\u002fquickedit\\u002eoctana\\u002eio/preview', + 'https://preview--topcoder.netlify.app/page', + ])('fails closed for an obscured retired URL in a compatibility response', async (url) => { + fetch.mockResolvedValue(response({ fields: { body: url } })); + + await expect(new ApiService('https://cms.topcoder-dev.com/spaces/a/environments/master', 'key') + .getEntry('entry-id')).rejects.toThrow('retired provider URL'); + }); + test('writes votes only through the configured Payload endpoint', async () => { fetch.mockResolvedValue(response({ updated: true })); diff --git a/src/server/services/cms-urls.js b/src/server/services/cms-urls.js index 3c77a8168..5ac69360a 100644 --- a/src/server/services/cms-urls.js +++ b/src/server/services/cms-urls.js @@ -4,15 +4,27 @@ import config from 'config'; +const RETIRED_CMS_NAME = ['content', 'ful'].join(''); +const RETIRED_HOSTING_NAME = ['net', 'lify'].join(''); +const RETIRED_FRAMEWORK_NAME = ['oct', 'ana'].join(''); const RETIRED_PROVIDER_HOST_SUFFIXES = [ - 'contentful.com', - 'ctfassets.net', - 'netlify.app', - 'netlify.com', - 'netlifyusercontent.com', - 'octana.io', + `${RETIRED_CMS_NAME}.com`, + ['ctf', 'assets.net'].join(''), + `${RETIRED_HOSTING_NAME}.app`, + `${RETIRED_HOSTING_NAME}.com`, + `${RETIRED_HOSTING_NAME}usercontent.com`, + `${RETIRED_FRAMEWORK_NAME}.io`, ]; -const URL_PATTERN = /(?:https?:)?\/\/[^\s<>"')\]]+/gi; +const NETWORK_AUTHORITY_PATTERN = /(^|[^a-z0-9._~/?#%-])(?:(?:https?):\/*|\/\/)(?:[^/?#\s]*@)?([a-z0-9.-]+)(?=[^a-z0-9.-]|$)/gi; +const MAX_DECODE_LAYERS = 5; +const NAMED_ENTITIES = { + bsol: '\\', + colon: ':', + newline: '\n', + period: '.', + sol: '/', + tab: '\t', +}; function isRetiredProviderHostname(hostname) { const normalized = hostname.toLowerCase(); @@ -26,6 +38,84 @@ function parseNetworkUrl(value, baseUrl) { return new URL(value, baseUrl); } +function decodedCodePoint(encoded, radix) { + const codePoint = Number.parseInt(encoded, radix); + return Number.isInteger(codePoint) && codePoint <= 0x10ffff + ? String.fromCodePoint(codePoint) + : null; +} + +/** Normalizes encodings a browser can turn into a network URL. */ +function normalizeBrowserText(value) { + let normalized = value; + for (let depth = 0; depth < MAX_DECODE_LAYERS; depth += 1) { + const decoded = normalized + .replace(/\\u([a-f0-9]{4})/gi, (match, hex) => ( + decodedCodePoint(hex, 16) || match + )) + .replace(/\\u\{([a-f0-9]{1,6})\}/gi, (match, hex) => ( + decodedCodePoint(hex, 16) || match + )) + .replace(/\\x([a-f0-9]{2})/gi, (match, hex) => ( + decodedCodePoint(hex, 16) || match + )) + .replace(/&#x([a-f0-9]+);?/gi, (match, hex) => ( + decodedCodePoint(hex, 16) || match + )) + .replace(/&#([0-9]+);?/g, (match, decimal) => ( + decodedCodePoint(decimal, 10) || match + )) + .replace(/&(colon|sol|period|bsol|tab|newline);/gi, (match, name) => ( + NAMED_ENTITIES[name.toLowerCase()] || match + )); + if (decoded === normalized) break; + normalized = decoded; + } + return normalized + .replace(/[。.。]/g, '.') + .replace(/[\t\n\r\f\v]/g, '') + .replace(/\\\//g, '/') + .replace(/\\/g, '/'); +} + +/** Decodes valid percent-byte runs without one malformed escape hiding others. */ +function decodePercentBytes(value) { + return value.replace(/(?:%[a-f0-9]{2})+/gi, (encoded) => { + try { + return decodeURIComponent(encoded); + } catch (error) { + return encoded.replace(/%([a-f0-9]{2})/gi, (match, hex) => ( + String.fromCharCode(Number.parseInt(hex, 16)) + )); + } + }); +} + +/** Returns true when any URL authority in normalized text is retired. */ +function hasRetiredProviderAuthority(value) { + NETWORK_AUTHORITY_PATTERN.lastIndex = 0; + let match = NETWORK_AUTHORITY_PATTERN.exec(value); + while (match) { + const hostname = match[2].toLowerCase().replace(/\.+$/, ''); + if (isRetiredProviderHostname(hostname)) return true; + match = NETWORK_AUTHORITY_PATTERN.exec(value); + } + return false; +} + +/** Detects raw, browser-escaped, nested, and repeatedly encoded URLs. */ +function containsRetiredProviderUrl(value) { + let layer = value; + for (let depth = 0; depth <= MAX_DECODE_LAYERS; depth += 1) { + const normalized = normalizeBrowserText(layer); + if (hasRetiredProviderAuthority(normalized)) return true; + const decoded = decodePercentBytes(normalized); + if (decoded === normalized) return false; + layer = decoded; + } + return false; +} + function isTopcoderOrLocalHostname(hostname) { return hostname === 'localhost' || hostname === '127.0.0.1' @@ -63,23 +153,17 @@ export function assertNoRetiredCmsUrls(value) { function inspect(current) { if (typeof current === 'string') { - const matches = current.match(URL_PATTERN) || []; - matches.forEach((match) => { - let parsed; - try { - parsed = parseNetworkUrl(match, 'https://localhost'); - } catch (error) { - return; - } - if (isRetiredProviderHostname(parsed.hostname)) { - throw new Error('Payload CMS response contains a retired provider URL.'); - } - }); + if (containsRetiredProviderUrl(current)) { + throw new Error('Payload CMS response contains a retired provider URL.'); + } return; } if (!current || typeof current !== 'object' || visited.has(current)) return; visited.add(current); - Object.keys(current).forEach(key => inspect(current[key])); + Object.keys(current).forEach((key) => { + inspect(key); + inspect(current[key]); + }); } inspect(value); From 4246181ddd311add5861b03254c2ed7bad1cdfb7 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 26 Aug 2026 18:31:08 +1000 Subject: [PATCH 3/3] Harden Payload compatibility requests --- README.md | 1 + __tests__/server/contentful.js | 73 ++++++++++++++++++++++++++ config/custom-environment-variables.js | 1 + config/default.js | 2 + docs/contentful/environment-setup.md | 8 +-- src/server/services/contentful.js | 28 +++++++++- 6 files changed, 108 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e4789d4b4..b06a2ec4a 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ If you need any operations related to currency conversions, pay attention to the - `CONTENTFUL_PREVIEW_API_KEY` - `CONTENTFUL_CDN_API_HOST` - `CONTENTFUL_PREVIEW_API_HOST` + - `CONTENTFUL_PAYLOAD_REQUEST_TIMEOUT_MS` (optional; defaults to 10000) See [Payload CMS compatibility environment setup](docs/contentful/environment-setup.md) for supported spaces, S3 asset configuration, and article-vote write-through. diff --git a/__tests__/server/contentful.js b/__tests__/server/contentful.js index 21dd94f3e..62cfcea5c 100644 --- a/__tests__/server/contentful.js +++ b/__tests__/server/contentful.js @@ -15,6 +15,7 @@ jest.mock('config', () => ({ CONTENTFUL: { DEFAULT_ENVIRONMENT: 'master', DEFAULT_SPACE_NAME: 'default', + PAYLOAD_REQUEST_TIMEOUT_MS: 4321, }, SECRET: { CONTENTFUL: { @@ -77,6 +78,7 @@ describe('server/services/contentful Payload compatibility client', () => { expect(options.redirect).toBe('manual'); expect(options.agent.options.keepAlive).toBe(true); expect(options.headers.Authorization).toBe('Bearer delivery-key'); + expect(options.timeout).toBe(4321); }); test('fails closed for an unsupported space before making a request', () => { @@ -110,6 +112,67 @@ describe('server/services/contentful Payload compatibility client', () => { expect(fetch.mock.calls[0][0]).toContain('/entries?fields.slug=payload%20article'); }); + test('preserves getEntry link resolution through the compatibility collection', async () => { + fetch.mockResolvedValue(response({ + items: [{ + fields: { + avatar: { sys: { id: 'avatar-id', linkType: 'Asset', type: 'Link' } }, + }, + sys: { id: 'member-id', type: 'Entry' }, + }], + includes: { + Asset: [{ + fields: { file: { url: 'https://assets.topcoder-dev.com/member.png' } }, + sys: { id: 'avatar-id', type: 'Asset' }, + }], + }, + limit: 100, + skip: 0, + total: 1, + })); + + const entry = await new ApiService( + 'https://cms.topcoder-dev.com/spaces/a/environments/master', + 'key', + ).getEntry('member-id'); + + expect(entry.fields.avatar.fields.file.url) + .toBe('https://assets.topcoder-dev.com/member.png'); + expect(fetch.mock.calls[0][0]) + .toBe('https://cms.topcoder-dev.com/spaces/a/environments/master/entries?sys.id=member-id&limit=1'); + }); + + test.each([ + [500, 1000], + [60000, 30000], + ['invalid', 10000], + ])('keeps configured request timeout %p within safe bounds', async (configured, expected) => { + const originalTimeout = config.CONTENTFUL.PAYLOAD_REQUEST_TIMEOUT_MS; + config.CONTENTFUL.PAYLOAD_REQUEST_TIMEOUT_MS = configured; + fetch.mockResolvedValue(response({ fields: {}, sys: { id: 'asset-id' } })); + try { + await new ApiService( + 'https://cms.topcoder-dev.com/spaces/a/environments/master', + 'key', + ).getAsset('asset-id'); + expect(fetch.mock.calls[0][1].timeout).toBe(expected); + } finally { + config.CONTENTFUL.PAYLOAD_REQUEST_TIMEOUT_MS = originalTimeout; + } + }); + + test('propagates a Payload request timeout without retrying it', async () => { + const timeoutError = new Error('network timeout'); + fetch.mockRejectedValue(timeoutError); + + await expect(new ApiService( + 'https://cms.topcoder-dev.com/spaces/a/environments/master', + 'key', + ).getAsset('asset-id')).rejects.toBe(timeoutError); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0][1].timeout).toBe(4321); + }); + test('rejects compatibility responses containing retired asset URLs', async () => { fetch.mockResolvedValue(response({ fields: { file: { url: `//${RETIRED_ASSET_HOST}/space/asset/file.png` } }, @@ -145,6 +208,7 @@ describe('server/services/contentful Payload compatibility client', () => { expect(fetch.mock.calls[0][1]).toMatchObject({ method: 'POST', redirect: 'manual', + timeout: 4321, body: JSON.stringify({ spaceId: 'edu-space', environment: 'master', @@ -154,6 +218,15 @@ describe('server/services/contentful Payload compatibility client', () => { }); }); + test('rejects retired provider URLs in vote responses', async () => { + fetch.mockResolvedValue(response({ redirect: RETIRED_ASSET_URL })); + + await expect(articleVote({ + id: 'article-id', + votes: { downvotes: 1, upvotes: 2 }, + }, 'EDU', 'master')).rejects.toThrow('retired provider URL'); + }); + test('does not fall back when vote write-through is unconfigured', async () => { const originalUrl = config.SECRET.CONTENTFUL.PAYLOAD_VOTE_API_URL; config.SECRET.CONTENTFUL.PAYLOAD_VOTE_API_URL = ''; diff --git a/config/custom-environment-variables.js b/config/custom-environment-variables.js index ab31a04bd..d102b2e61 100644 --- a/config/custom-environment-variables.js +++ b/config/custom-environment-variables.js @@ -4,6 +4,7 @@ module.exports = { CONTENTFUL: { LOCAL_MODE: 'CONTENTFUL_LOCAL_MODE', + PAYLOAD_REQUEST_TIMEOUT_MS: 'CONTENTFUL_PAYLOAD_REQUEST_TIMEOUT_MS', }, AUTH0: { CLIENT_ID: 'AUTH0_CLIENT_ID', diff --git a/config/default.js b/config/default.js index d3b66cfd6..1a82241c7 100644 --- a/config/default.js +++ b/config/default.js @@ -55,6 +55,8 @@ module.exports = { DEFAULT_SPACE_NAME: 'default', DEFAULT_ENVIRONMENT: 'master', CHANGELOG_ID: '5ULnHeUIuYAyLhNO97zAqy', + /* Per-request timeout for Payload compatibility reads and writes. */ + PAYLOAD_REQUEST_TIMEOUT_MS: 10 * 1000, }, /** diff --git a/docs/contentful/environment-setup.md b/docs/contentful/environment-setup.md index 403377724..cb36ad808 100644 --- a/docs/contentful/environment-setup.md +++ b/docs/contentful/environment-setup.md @@ -45,13 +45,15 @@ export PAYLOAD_CMS_URL="https://cms.topcoder-dev.com" export PAYLOAD_CMS_ASSET_URL="https://assets.topcoder-dev.com" export CONTENTFUL_PAYLOAD_VOTE_API_URL="https://cms.topcoder-dev.com/contentful-management/votes" export CONTENTFUL_PAYLOAD_MANAGEMENT_API_KEY="" +# Optional; defaults to 10000 and is clamped to the 1000-30000 ms range. +export CONTENTFUL_PAYLOAD_REQUEST_TIMEOUT_MS="10000" ``` The vote URL and credential are required for article voting. There is no management-API fallback. Compatibility API and vote requests do not follow HTTP -redirects. Asset redirects are accepted only when their destination matches -`PAYLOAD_CMS_ASSET_URL`; compatibility responses containing retired provider -URLs are rejected. +redirects, and every request uses the bounded timeout above. Asset redirects are +accepted only when their destination matches `PAYLOAD_CMS_ASSET_URL`; +compatibility responses containing retired provider URLs are rejected. Use production equivalents (`cms.topcoder.com` and `assets.topcoder.com`) in production. Run `npm run verify:no-retired-cms-targets` after building to scan diff --git a/src/server/services/contentful.js b/src/server/services/contentful.js index af13c8ba1..8197e3791 100644 --- a/src/server/services/contentful.js +++ b/src/server/services/contentful.js @@ -21,6 +21,9 @@ import { assertNoRetiredCmsUrls } from './cms-urls'; const cmsHttpsAgent = new https.Agent({ keepAlive: true }); const MAX_FETCH_RETRIES = 5; +const DEFAULT_REQUEST_TIMEOUT_MS = 10 * 1000; +const MIN_REQUEST_TIMEOUT_MS = 1000; +const MAX_REQUEST_TIMEOUT_MS = 30 * 1000; function threeSecondDelay() { return new Promise(resolve => setTimeout(resolve, 3000)); @@ -32,6 +35,22 @@ function decodeQuery(value) { return typeof value === 'string' ? decodeURIComponent(value) : value; } +/** Returns a finite timeout accepted by the Node 10/node-fetch 1.x client. */ +function getRequestTimeout() { + const value = _.get(config, 'CONTENTFUL.PAYLOAD_REQUEST_TIMEOUT_MS'); + if (value === undefined || value === null || value === '') { + return DEFAULT_REQUEST_TIMEOUT_MS; + } + const milliseconds = Number(value); + if (!Number.isFinite(milliseconds) || milliseconds <= 0) { + return DEFAULT_REQUEST_TIMEOUT_MS; + } + return Math.min( + MAX_REQUEST_TIMEOUT_MS, + Math.max(MIN_REQUEST_TIMEOUT_MS, Math.floor(milliseconds)), + ); +} + function toSerializableEntryCollection(data) { const collection = { ...data, @@ -63,6 +82,7 @@ export class ApiService { agent: cmsHttpsAgent, headers: { Authorization: `Bearer ${this.private.key}` }, redirect: 'manual', + timeout: getRequestTimeout(), }); if (res.status !== 429) break; await threeSecondDelay(); @@ -80,7 +100,10 @@ export class ApiService { } async getEntry(id) { - return this.fetch(`/entries/${encodeURIComponent(id)}`); + if (!id) throw new Error('Payload CMS entry ID is required.'); + const collection = await this.queryEntries({ 'sys.id': id, limit: 1 }); + if (collection.items.length) return collection.items[0]; + throw new Error(`Payload CMS entry '${id}' was not found.`); } async queryAssets(query) { @@ -137,6 +160,7 @@ export function articleVote(body, spaceName = 'EDU', environment = 'master') { 'Content-Type': 'application/json', }, redirect: 'manual', + timeout: getRequestTimeout(), body: JSON.stringify({ spaceId, environment, @@ -147,7 +171,7 @@ export function articleVote(body, spaceName = 'EDU', environment = 'master') { if (!response.ok) { throw new Error(`Payload article vote update failed with status ${response.status}.`); } - return response.json(); + return response.json().then(data => assertNoRetiredCmsUrls(data)); }); }