Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Please check the [https://github.com/topcoder-platform/community-app/blob/master/CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on rules to be followed.
88 changes: 88 additions & 0 deletions __tests__/server/cms-urls.js
Original file line number Diff line number Diff line change
@@ -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: `![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://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);
});
});
19 changes: 15 additions & 4 deletions __tests__/server/contentful-endpoints.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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.');
Expand Down
57 changes: 57 additions & 0 deletions __tests__/server/contentful-routes.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading