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..b06a2ec4a 100644
--- a/README.md
+++ b/README.md
@@ -55,18 +55,19 @@ 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`
+ - `CONTENTFUL_PAYLOAD_REQUEST_TIMEOUT_MS` (optional; defaults to 10000)
- 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 +199,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..42f993937
--- /dev/null
+++ b/__tests__/server/cms-urls.js
@@ -0,0 +1,88 @@
+import {
+ assertNoRetiredCmsUrls,
+ getPayloadAppUrl,
+ 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');
+ });
+
+ 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(`//${RETIRED_ASSET_HOST}/space/asset/image.png`))
+ .toThrow('retired provider URL');
+ expect(() => assertNoRetiredCmsUrls({
+ fields: { body: `` },
+ })).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://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('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-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..62cfcea5c 100644
--- a/__tests__/server/contentful.js
+++ b/__tests__/server/contentful.js
@@ -1,51 +1,238 @@
/* 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');
+const RETIRED_ASSET_HOST = ['images.', 'ctf', 'assets.net'].join('');
+const RETIRED_ASSET_URL = `https://${RETIRED_ASSET_HOST}/space/asset/file.png`;
-jest.mock('contentful', () => ({
- createClient: jest.fn(() => ({})),
+jest.mock('config', () => ({
+ CONTENTFUL: {
+ DEFAULT_ENVIRONMENT: 'master',
+ DEFAULT_SPACE_NAME: 'default',
+ PAYLOAD_REQUEST_TIMEOUT_MS: 4321,
+ },
+ 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' } }));
-describe('server/services/contentful HTTPS connections', () => {
- test('shares one keep-alive agent across Delivery, Preview, and Management clients', async () => {
- getService('default', 'master', false);
+ 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');
+ expect(options.timeout).toBe(4321);
+ });
+
+ 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('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` } },
+ 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.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 }));
+
+ 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',
+ timeout: 4321,
+ body: JSON.stringify({
+ spaceId: 'edu-space',
+ environment: 'master',
+ entryId: 'article-id',
+ votes: { downvotes: 1, upvotes: 2 },
+ }),
});
+ });
+
+ 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');
+ });
- 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..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',
@@ -30,6 +31,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 +39,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..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,
},
/**
@@ -111,6 +113,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 +213,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..cb36ad808 100644
--- a/docs/contentful/environment-setup.md
+++ b/docs/contentful/environment-setup.md
@@ -1,81 +1,61 @@
-# 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=""
+# Optional; defaults to 10000 and is clamped to the 1000-30000 ms range.
+export CONTENTFUL_PAYLOAD_REQUEST_TIMEOUT_MS="10000"
```
-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, 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.
-`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..5ac69360a
--- /dev/null
+++ b/src/server/services/cms-urls.js
@@ -0,0 +1,205 @@
+/**
+ * Runtime URL guards for Payload CMS responses and redirects.
+ */
+
+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 = [
+ `${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 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();
+ 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 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(/([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'
+ || 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') {
+ 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(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..8197e3791 100644
--- a/src/server/services/contentful.js
+++ b/src/server/services/contentful.js
@@ -1,289 +1,210 @@
/**
- * 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 });
+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;
-export const ASSETS_DOMAIN = 'assets.ctfassets.net';
-export const IMAGES_DOMAIN = 'images.ctfassets.net';
+function threeSecondDelay() {
+ return new Promise(resolve => setTimeout(resolve, 3000));
+}
-export const ALLOWED_DOMAINS = [ASSETS_DOMAIN, IMAGES_DOMAIN];
-const MAX_FETCH_RETRIES = 5;
+function decodeQuery(value) {
+ if (_.isArray(value)) return value.map(decodeQuery);
+ if (_.isPlainObject(value)) return _.mapValues(value, decodeQuery);
+ return typeof value === 'string' ? decodeURIComponent(value) : value;
+}
-/**
- * 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);
+/** 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)),
+ );
}
-/**
- * Creates a promise that resolves two second after its creation.
- * @return {Promise}
- */
-function threeSecondDelay() {
- return new Promise(resolve => setTimeout(resolve, 3000));
+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',
+ },
+ })));
}
-/**
- * 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);
+/** 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',
+ timeout: getRequestTimeout(),
});
- /* 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;
+ 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.`);
}
- /**
- * 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