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
7 changes: 7 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Release Notes
=============

Version 0.71.4
--------------

- Exclude MicroMasters FIN programs from ETL ingestion (#3458)
- feat: add surrogate-key in the header for Fastly (#3393)
- fix: pick cover image of video if no image is inserted in article content (#3433)

Version 0.71.3 (Released June 10, 2026)
--------------

Expand Down
6 changes: 6 additions & 0 deletions frontends/api/src/generated/v1/api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion frontends/api/src/hooks/website_content/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ const useWebsiteContentCreate = () => {
mutationFn: (
data: Omit<
WebsiteContent,
"id" | "user" | "created_on" | "updated_on" | "publish_date"
| "id"
| "user"
| "created_on"
| "updated_on"
| "publish_date"
| "cover_image"
>,
) =>
websiteContentApi
Expand Down
1 change: 1 addition & 0 deletions frontends/api/src/test-utils/factories/websiteContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { WebsiteContent } from "../../generated/v1"
const websiteContent: Factory<WebsiteContent> = (overrides = {}) => ({
id: faker.number.int(),
title: faker.lorem.sentence(),
cover_image: faker.image.url(),
content: {
type: "doc",
content: [
Expand Down
37 changes: 24 additions & 13 deletions frontends/main/src/common/websiteContentUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,19 +94,30 @@ export function extractArticleContent(
const banner = topLevel.find((n) => n.type === "banner")
const headingNode = banner?.content?.find((n) => n.type === "heading")
const paragraphNode = banner?.content?.find((n) => n.type === "paragraph")

const imageNode = topLevel.find((n) => n.type === "imageWithCaption")
const imageAttrs = imageNode?.attrs
const image =
imageAttrs?.src && typeof imageAttrs.src === "string"
? {
src: imageAttrs.src,
alt: typeof imageAttrs.alt === "string" ? imageAttrs.alt : null,
caption:
typeof imageAttrs.caption === "string" ? imageAttrs.caption : null,
}
: null

let image = null
if (article?.cover_image) {
image = {
src: article?.cover_image || "",
alt: "",
caption: "",
}
}
if (image === null) {
const imageNode = topLevel.find((n) => n.type === "imageWithCaption")
const imageAttrs = imageNode?.attrs

image =
imageAttrs?.src && typeof imageAttrs.src === "string"
? {
src: imageAttrs.src,
alt: typeof imageAttrs.alt === "string" ? imageAttrs.alt : null,
caption:
typeof imageAttrs.caption === "string"
? imageAttrs.caption
: null,
}
: null
}
return {
heading: extractText(headingNode?.content) || null,
paragraph: nodesToHtml(paragraphNode?.content) || null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,11 @@ const StyledWrapper = styled.div(({ theme }) => ({
border: `1px solid ${theme.custom.colors.lightGray2}`,
}))

const InnerContainer = styled(Container, {
shouldForwardProp: (prop) => prop !== "noAuthor",
})<{ noAuthor?: boolean }>(({ noAuthor }) => ({
const InnerContainer = styled(Container)(() => ({
display: "flex",
justifyContent: noAuthor ? "flex-end" : "space-between",
justifyContent: "space-between",
alignItems: "center",
gap: "8px",
"&&": {
maxWidth: "890px",
},
Expand Down Expand Up @@ -126,38 +125,38 @@ export const ByLineInfoBarContent = ({
onClose={() => setShareOpen(false)}
pageUrl={`${NEXT_PUBLIC_ORIGIN}/${article?.content_type === "article" ? "articles" : "news"}/${article?.slug}`}
/>
<InnerContainer noAuthor={!displayAuthorName && !isEditable}>
{(displayAuthorName || isEditable) && (
<InfoContainer>
{isEditable ? (
<AuthorInput
name="authorName"
label=""
size="small"
placeholder="Add author name(s)"
value={authorName || ""}
onChange={(e) => onAuthorNameChange?.(e.target.value)}
/>
) : (
<NameText>By {displayAuthorName}</NameText>
)}
{readTime ? <InfoText>{readTime} min read</InfoText> : null}
{readTime && publishedDate ? (
<Separator className="byline-info-bar__separator" />
) : null}
<InfoText>
{publishedDate
? new Date(publishedDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
: isEditable
? null
: "Draft"}
</InfoText>
</InfoContainer>
)}
<InnerContainer>
<InfoContainer>
{isEditable ? (
<AuthorInput
name="authorName"
label=""
size="small"
placeholder="Add author name(s)"
value={authorName || ""}
onChange={(e) => onAuthorNameChange?.(e.target.value)}
/>
) : (
<>
{displayAuthorName && <NameText>By {displayAuthorName}</NameText>}
</>
)}
{readTime ? <InfoText>{readTime} min read</InfoText> : null}
{readTime && publishedDate ? (
<Separator className="byline-info-bar__separator" />
) : null}
<InfoText>
{publishedDate
? new Date(publishedDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
: isEditable
? null
: "Draft"}
</InfoText>
</InfoContainer>
<div ref={shareButtonRef}>
<ActionButton
size="small"
Expand Down
123 changes: 120 additions & 3 deletions frontends/main/src/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextRequest } from "next/server"
import { isPageRoute, proxy } from "./proxy"
import { isPageRoute, mitxonlineSurrogateKey, proxy } from "./proxy"

describe("isPageRoute", () => {
test.each([
Expand Down Expand Up @@ -33,19 +33,136 @@ describe("isPageRoute", () => {
})
})

describe("mitxonlineSurrogateKey", () => {
describe("course pages — /courses/:readable_id", () => {
it("returns mitxonline:course key", () => {
expect(
mitxonlineSurrogateKey("/courses/course-v1:MITx+6.00.1x+3T2019"),
).toBe("mitxonline:course:course-v1:MITx+6.00.1x+3T2019")
})

it("handles URL-encoded readable_id", () => {
const encoded = encodeURIComponent("course-v1:MITx+6.00.1x+3T2019")
expect(mitxonlineSurrogateKey(`/courses/${encoded}`)).toBe(
"mitxonline:course:course-v1:MITx+6.00.1x+3T2019",
)
})
})

describe("program pages — /programs/:readable_id and /courses/p/:readable_id", () => {
it("returns mitxonline:program key for /programs/:readable_id", () => {
expect(mitxonlineSurrogateKey("/programs/program-v1:MITx+SDS")).toBe(
"mitxonline:program:program-v1:MITx+SDS",
)
})

it("returns mitxonline:program key for /courses/p/:readable_id (ProgramAsCoursePage)", () => {
// /courses/p/ renders ProgramAsCoursePage — the readable_id belongs to a
// program, so the surrogate key must use the program namespace so that
// MITxOnline's program-save signal purges this page correctly.
expect(mitxonlineSurrogateKey("/courses/p/program-v1:MITx+SDS")).toBe(
"mitxonline:program:program-v1:MITx+SDS",
)
})
})

describe("non-product pages", () => {
it.each(["/", "/search", "/about", "/courses", "/programs"])(
"returns null for %s",
(pathname) => {
expect(mitxonlineSurrogateKey(pathname)).toBeNull()
},
)
})

describe("hostile URL segments — regression: Headers.set() crash on control characters", () => {
it("returns null without throwing on malformed percent-encoding in course path", () => {
// %GG is not valid percent-encoding — decodeURIComponent would throw URIError
expect(() => mitxonlineSurrogateKey("/courses/%GG")).not.toThrow()
expect(mitxonlineSurrogateKey("/courses/%GG")).toBeNull()
})

it("returns null without throwing on malformed percent-encoding in program path", () => {
expect(() => mitxonlineSurrogateKey("/programs/%GG")).not.toThrow()
expect(mitxonlineSurrogateKey("/programs/%GG")).toBeNull()
})

it("returns null on CRLF in course path (%0D%0A decodes to \\r\\n)", () => {
// Without safeDecodeSegment, passing this to Headers.set() would throw TypeError
expect(() =>
mitxonlineSurrogateKey("/courses/foo%0D%0AX-Injected%3A+yes"),
).not.toThrow()
expect(
mitxonlineSurrogateKey("/courses/foo%0D%0AX-Injected%3A+yes"),
).toBeNull()
})

it("returns null on CRLF in program path", () => {
expect(() =>
mitxonlineSurrogateKey("/programs/foo%0D%0AX-Injected%3A+yes"),
).not.toThrow()
expect(
mitxonlineSurrogateKey("/programs/foo%0D%0AX-Injected%3A+yes"),
).toBeNull()
})

it("returns null on null byte in course path", () => {
expect(() => mitxonlineSurrogateKey("/courses/foo%00bar")).not.toThrow()
expect(mitxonlineSurrogateKey("/courses/foo%00bar")).toBeNull()
})

it("returns null on null byte in program path", () => {
expect(() => mitxonlineSurrogateKey("/programs/foo%00bar")).not.toThrow()
expect(mitxonlineSurrogateKey("/programs/foo%00bar")).toBeNull()
})
})
})

describe("proxy", () => {
const makeRequest = (pathname: string) =>
new NextRequest(new URL(pathname, "https://learn.mit.edu"))

test("tags page routes with both Cache-Control and Surrogate-Key", () => {
const response = proxy(makeRequest("/courses/course-v1:MITxT+5.601x"))
test("tags generic page routes with Cache-Control and html-pages Surrogate-Key", () => {
const response = proxy(makeRequest("/about"))
expect(response.headers.get("Surrogate-Key")).toBe("html-pages")
expect(response.headers.get("Cache-Control")).toContain("s-maxage=")
})

test("appends per-item surrogate key for MITxOnline course pages", () => {
const response = proxy(makeRequest("/courses/course-v1:MITxT+5.601x"))
expect(response.headers.get("Surrogate-Key")).toBe(
"html-pages mitxonline:course:course-v1:MITxT+5.601x",
)
expect(response.headers.get("Cache-Control")).toContain("s-maxage=")
})

test("appends per-item surrogate key for MITxOnline program pages (/programs/)", () => {
const response = proxy(makeRequest("/programs/program-v1:MITxT+18.01x"))
expect(response.headers.get("Surrogate-Key")).toBe(
"html-pages mitxonline:program:program-v1:MITxT+18.01x",
)
})

test("appends mitxonline:program surrogate key for /courses/p/ (ProgramAsCoursePage)", () => {
const response = proxy(makeRequest("/courses/p/program-v1:MITxT+18.01x"))
expect(response.headers.get("Surrogate-Key")).toBe(
"html-pages mitxonline:program:program-v1:MITxT+18.01x",
)
})

test("leaves non-page routes untagged", () => {
const response = proxy(makeRequest("/healthcheck"))
expect(response.headers.get("Surrogate-Key")).toBeNull()
expect(response.headers.get("Cache-Control")).toBeNull()
})

test("does not throw on CRLF in course path and falls back to html-pages only", () => {
// Regression: without safeDecodeSegment, Headers.set() would throw TypeError
// when the decoded segment contains \r\n (%0D%0A).
expect(() =>
proxy(makeRequest("/courses/foo%0D%0AX-Injected%3A+yes")),
).not.toThrow()
const response = proxy(makeRequest("/courses/foo%0D%0AX-Injected%3A+yes"))
expect(response.headers.get("Surrogate-Key")).toBe("html-pages")
})
})
Loading
Loading