Skip to content

Add a new homepage to the dev server #14338

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
194 changes: 194 additions & 0 deletions dotcom-rendering/src/devServer/docs/home.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import {
articleBold17Object,
headlineBold20Object,
palette,
space,
until,
} from '@guardian/source/foundations';
import {
LinkButton,
SvgArrowRightStraight,
TextInput,
} from '@guardian/source/react-components';
import { formatToString } from '../../lib/articleFormat';
import type { Article as ArticleModel } from '../../types/article';
import type { Block } from '../../types/blocks';
import type { FEElement } from '../../types/content';
import type { RenderingTarget } from '../../types/renderingTarget';

export const Home = ({ articles }: { articles: ArticleModel[] }) => (
<>
<Form />
{articles.map((article) => (
<ArticleInfo article={article} key={article.frontendData.webURL} />
))}
</>
);

const Form = () => (
<form method="GET" css={{ paddingBottom: space[9] }}>
<TextInput
type="url"
name="dotcomURL"
id="dotcomURL"
placeholder="https://www.theguardian.com"
label="Dotcom URL"
/>
</form>
);

const ArticleInfo = ({ article }: { article: ArticleModel }) => (
<details open={true}>
<summary css={headlineBold20Object}>
{article.frontendData.webTitle}
</summary>
<ArticleLinks url={new URL(article.frontendData.webURL)} />
<dl
css={{
marginTop: space[2],
dt: articleBold17Object,
}}
>
<dt>Format</dt>
<dd>{formatToString(article)}</dd>
<dt>Main Media</dt>
<dd>{mainMediaElement(article.frontendData.mainMediaElements)}</dd>
<dt>Body</dt>
<dd>{bodyElements(article.frontendData.blocks)}</dd>
</dl>
</details>
);

const ArticleLinks = ({ url }: { url: URL }) => (
<div css={{ paddingTop: space[2] }}>
<StageLinks stage="DEV" url={url} />
<StageLinks stage="CODE" url={url} />
<StageLinks stage="PROD" url={url} />
</div>
);

const mainMediaElement = (elements: FEElement[]): string => {
const element = elements[0];

return element !== undefined ? elementToString(element) : 'none';
};

const bodyElements = (blocks: Block[]): string =>
blocks
.flatMap((block) => block.elements)
.map(elementToString)
.reduce(mergeText, [])
.join(', ');

const elementToString = (element: FEElement): string => {
const role = 'role' in element ? ` (${element.role})` : '';
const elem = element._type.split('.').at(-1)?.replace('BlockElement', '');
Copy link
Preview

Copilot AI Aug 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using optional chaining with .at(-1) but not handling the undefined case. If split() returns an empty array, .at(-1) returns undefined, and calling .replace() on undefined will throw an error.

Suggested change
const elem = element._type.split('.').at(-1)?.replace('BlockElement', '');
const elem = (element._type.split('.').at(-1) ?? '').replace('BlockElement', '');

Copilot uses AI. Check for mistakes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is incorrect for a couple of reasons:

  1. split will never return an empty array in this case, as separator is not an empty string and the limit argument has not been passed1.
  2. Even if it did, an error would not be thrown because optional chaining is used; therefore the expression will short-circuit to undefined (not helpful perhaps, but not an error).

Footnotes

  1. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/split


return `${elem}${role}`;
};

const mergeText = (elems: string[], elem: string) => {
if (elem === 'Text' && elems.at(-1) === 'Text') {
return elems;
}

return [...elems, elem];
};

const StageLinks = ({ stage, url }: { stage: Stage; url: URL }) => (
<div
css={{
display: 'inline-flex',
flexDirection: 'column',
gap: space[2],
paddingRight: space[2],
paddingTop: space[2],
paddingBottom: space[2],
[until.mobileLandscape]: {
width: '100%',
},
a: {
color: stageTextColour(stage),
display: 'flex',
justifyContent: 'space-between',
},
}}
>
<LinkButton
href={buildUrl('Apps', stage, url).href}
icon={<SvgArrowRightStraight />}
iconSide="right"
theme={{ backgroundPrimary: stageColour(stage) }}
size="small"
>
📱 Apps {stage}
</LinkButton>
<LinkButton
href={buildUrl('Web', stage, url).href}
icon={<SvgArrowRightStraight />}
iconSide="right"
theme={{ backgroundPrimary: stageColour(stage) }}
size="small"
>
🌍 Dotcom {stage}
</LinkButton>
</div>
);

type Stage = 'DEV' | 'CODE' | 'PROD';

const buildUrl = (target: RenderingTarget, stage: Stage, url: URL) => {
switch (stage) {
case 'PROD': {
const builtUrl = new URL(url.pathname, prodOrigin);
builtUrl.search = params(target).toString();

return builtUrl;
}
case 'CODE': {
const builtUrl = new URL(url.pathname, codeOrigin);
builtUrl.search = params(target).toString();

return builtUrl;
}
case 'DEV':
return new URL(`${devPath(target)}/${url.toString()}`, devOrigin);
}
};

const stageTextColour = (stage: Stage) => {
switch (stage) {
case 'DEV':
return palette.neutral[7];
case 'CODE':
case 'PROD':
return palette.neutral[100];
}
};

const stageColour = (stage: Stage) => {
switch (stage) {
case 'DEV':
return palette.brandAlt[400];
case 'CODE':
return palette.success[400];
case 'PROD':
return palette.brand[400];
}
};

const devOrigin = 'http://localhost:3030';
const codeOrigin = 'https://m.code.dev-theguardian.com';
const prodOrigin = 'https://www.theguardian.com';

const params = (target: RenderingTarget): URLSearchParams =>
new URLSearchParams(target === 'Apps' ? { dcr: 'apps' } : undefined);

const devPath = (target: RenderingTarget): string => {
switch (target) {
case 'Web':
return '/Article';
case 'Apps':
return '/AppsArticle';
}
};
78 changes: 78 additions & 0 deletions dotcom-rendering/src/devServer/routers/home.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { type Request, Router } from 'express';
import { error, ok, type Result } from '../../lib/result';
import { validateAsFEArticle } from '../../model/validate';
import { type Article, enhanceArticleType } from '../../types/article';
import { Home } from '../docs/home';
import { sendReact } from '../send';

export const home = Router();

home.get('/', async (req, res, next) => {
const dotcomURL = parseUrl(req);

if (dotcomURL.kind === 'ok') {
const article = await extractArticle(dotcomURL.value);

return sendReact('Home', <Home articles={[article]} />)(req, res, next);
} else if (dotcomURL.error.kind === 'noParameter') {
return sendReact('Home', <Home articles={[]} />)(req, res, next);
} else {
console.error(dotcomURL.error.message);
res.sendStatus(400);
}
});

const parseUrl = (req: Request): Result<UrlError, URL> => {
const param = req.query.dotcomURL;

if (param === undefined) {
return error({ kind: 'noParameter' });
}

if (typeof param !== 'string') {
return error({
kind: 'parseError',
message:
"'dotcomURL' query parameter must contain a single string.",
});
}

try {
const url = new URL(param);

if (url.host !== 'www.theguardian.com' || url.protocol !== 'https:') {
return error({
kind: 'parseError',
message:
"'dotcomURL' must start with 'https://www.theguardian.com'",
});
}

return ok(url);
} catch (_e) {
return error({
kind: 'parseError',
message: `Could not parse the 'dotcomURL' query parameter. Received: ${param}, expected: a valid URL string.`,
});
}
};

type UrlError =
| {
kind: 'noParameter';
}
| {
kind: 'parseError';
message: string;
};

const extractArticle = async (dotcomURL: URL): Promise<Article> => {
const url = new URL(dotcomURL);
url.pathname = `${url.pathname}.json`;
url.searchParams.append('dcr', 'true');

const json = await fetch(url).then((res) => res.json());
Copy link
Preview

Copilot AI Aug 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetch request lacks error handling. If the request fails or returns a non-200 status, the subsequent .json() call will fail without providing a helpful error message to the user.

Suggested change
const json = await fetch(url).then((res) => res.json());
let json;
try {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch article: ${res.status} ${res.statusText}`);
}
json = await res.json();
} catch (err) {
console.error('Error fetching article:', err);
throw err;
}

Copilot uses AI. Check for mistakes.

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

Copilot Autofix

AI 15 days ago

To fix the SSRF vulnerability, we should further restrict the user input so that only a safe, known set of paths can be requested from www.theguardian.com. The best way is to maintain an allow-list of permitted paths (e.g., specific article paths or path patterns), or at minimum, validate that the path matches an expected format (such as /[section]/[article-slug]). This validation should be performed in the parseUrl function, before the URL is returned and used in the fetch. If the path does not match the expected pattern, the request should be rejected.

Steps:

  • In parseUrl, after checking the host and protocol, add a check that the path matches a safe pattern (e.g., /[a-z0-9-]+(/[a-z0-9-]+)*).
  • Reject any path that contains suspicious elements (such as .., double slashes, or does not match the expected article path format).
  • Optionally, restrict or sanitize the query string as well, or disallow it entirely if not needed.

Required changes:

  • Edit parseUrl in dotcom-rendering/src/devServer/routers/home.tsx to add path validation.
  • No new imports are needed; use a regular expression for path validation.

Suggested changeset 1
dotcom-rendering/src/devServer/routers/home.tsx

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/dotcom-rendering/src/devServer/routers/home.tsx b/dotcom-rendering/src/devServer/routers/home.tsx
--- a/dotcom-rendering/src/devServer/routers/home.tsx
+++ b/dotcom-rendering/src/devServer/routers/home.tsx
@@ -50,2 +50,13 @@
 
+		// Restrict path to expected article format: e.g., /section/article-slug
+		// Disallow suspicious paths (e.g., path traversal, double slashes, etc.)
+		const pathPattern = /^\/[a-z0-9-]+(\/[a-z0-9-]+)*$/i;
+		if (!pathPattern.test(url.pathname)) {
+			return error({
+				kind: 'parseError',
+				message:
+					"'dotcomURL' path must match expected article format (e.g., /section/article-slug) and not contain suspicious characters.",
+			});
+		}
+
 		return ok(url);
EOF
@@ -50,2 +50,13 @@

// Restrict path to expected article format: e.g., /section/article-slug
// Disallow suspicious paths (e.g., path traversal, double slashes, etc.)
const pathPattern = /^\/[a-z0-9-]+(\/[a-z0-9-]+)*$/i;
if (!pathPattern.test(url.pathname)) {
return error({
kind: 'parseError',
message:
"'dotcomURL' path must match expected article format (e.g., /section/article-slug) and not contain suspicious characters.",
});
}

return ok(url);
Copilot is powered by AI and may make mistakes. Always verify output.
const frontendData = validateAsFEArticle(json);

return enhanceArticleType(frontendData, 'Web');
};
2 changes: 2 additions & 0 deletions dotcom-rendering/src/server/server.dev.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type Handler, Router } from 'express';
import { home } from '../devServer/routers/home';
import { pages } from '../devServer/routers/pages';
import { targets } from '../devServer/routers/targets';
import { handleAllEditorialNewslettersPage } from './handler.allEditorialNewslettersPage.web';
Expand Down Expand Up @@ -125,6 +126,7 @@ renderer.post('/CricketMatchPage', handleCricketMatchPage);
renderer.post('/FootballMatchSummaryPage', handleFootballMatchPage);

const router = Router();
router.use('/home', home);
router.use('/pages', pages);
router.use('/targets', targets);
router.use(renderer);
Expand Down