-
Notifications
You must be signed in to change notification settings - Fork 30
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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', ''); | ||
|
||
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'; | ||
} | ||
}; |
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()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback Check failureCode scanning / CodeQL Server-side request forgery Critical
The
URL Error loading related location Loading user-provided value Error loading related location Loading
Copilot AutofixAI 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 Steps:
Required changes:
Suggested changeset
1
dotcom-rendering/src/devServer/routers/home.tsx
Copilot is powered by AI and may make mistakes. Always verify output.
Positive FeedbackNegative Feedback
Refresh and try again.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
const frontendData = validateAsFEArticle(json); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return enhanceArticleType(frontendData, 'Web'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
}; |
There was a problem hiding this comment.
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.
Copilot uses AI. Check for mistakes.
There was a problem hiding this comment.
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:
split
will never return an empty array in this case, asseparator
is not an empty string and thelimit
argument has not been passed1.undefined
(not helpful perhaps, but not an error).Footnotes
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/split ↩