Skip to content

Commit b2d55e6

Browse files
authored
[codex] fix markdown accept negotiation (#142)
* fix markdown accept negotiation * drop stale headers from 406 responses * preserve origin errors for missing markdown routes
1 parent 232df1e commit b2d55e6

3 files changed

Lines changed: 379 additions & 23 deletions

File tree

‎src/markdown-negotiation-runtime.cjs‎

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
const {
2+
HTML_MEDIA_TYPE,
23
MARKDOWN_CONTENT_TYPE,
3-
acceptsMarkdown,
4+
MARKDOWN_MEDIA_TYPE,
45
appendHeaderValue,
56
isHtmlContentType,
67
isMarkdownPath,
8+
rankRepresentationTypes,
79
toMarkdownAssetPath,
810
} = require("./markdown-negotiation-shared.cjs");
911

@@ -19,8 +21,55 @@ function shouldRewriteMarkdownContentType(response) {
1921
return response.ok && !isHtmlContentType(response.headers.get("Content-Type"));
2022
}
2123

24+
const PASSTHROUGH_406_HEADERS = [
25+
"Access-Control-Allow-Origin",
26+
"Access-Control-Allow-Credentials",
27+
"Access-Control-Expose-Headers",
28+
"Content-Security-Policy",
29+
"Link",
30+
"Permissions-Policy",
31+
"Referrer-Policy",
32+
"Report-To",
33+
"Strict-Transport-Security",
34+
"X-Content-Type-Options",
35+
"X-Frame-Options",
36+
"X-Robots-Tag",
37+
"X-XSS-Protection",
38+
];
39+
40+
function notAcceptableResponse({ acceptHeader, method, response }) {
41+
const headers = new Headers();
42+
for (const headerName of PASSTHROUGH_406_HEADERS) {
43+
const headerValue = response.headers.get(headerName);
44+
if (headerValue) {
45+
headers.set(headerName, headerValue);
46+
}
47+
}
48+
49+
headers.set("Cache-Control", "no-store");
50+
headers.set("Content-Type", "text/plain; charset=utf-8");
51+
appendHeaderValue(headers, "Vary", "Accept");
52+
53+
const lines = [
54+
"This resource is available in:",
55+
`- ${HTML_MEDIA_TYPE}`,
56+
`- ${MARKDOWN_MEDIA_TYPE}`,
57+
];
58+
59+
if (acceptHeader) {
60+
lines.push("", `You requested: ${acceptHeader}`);
61+
}
62+
63+
return new Response(method === "HEAD" ? null : lines.join("\n"), {
64+
headers,
65+
status: 406,
66+
statusText: "Not Acceptable",
67+
});
68+
}
69+
2270
async function negotiateMarkdownResponse({ assetsFetch, request, response }) {
2371
const url = new URL(request.url);
72+
const acceptHeader = request.headers.get("Accept");
2473

2574
if (isMarkdownPath(url.pathname)) {
2675
if (!shouldRewriteMarkdownContentType(response)) {
@@ -36,16 +85,37 @@ async function negotiateMarkdownResponse({ assetsFetch, request, response }) {
3685
return response;
3786
}
3887

88+
const preferredRepresentations = rankRepresentationTypes(
89+
acceptHeader,
90+
[HTML_MEDIA_TYPE, MARKDOWN_MEDIA_TYPE],
91+
HTML_MEDIA_TYPE,
92+
);
93+
if (!preferredRepresentations.length) {
94+
return notAcceptableResponse({
95+
acceptHeader,
96+
method: request.method,
97+
response,
98+
});
99+
}
100+
39101
const htmlHeaders = new Headers(response.headers);
40102
appendHeaderValue(htmlHeaders, "Vary", "Accept");
41103

42-
if (!acceptsMarkdown(request.headers.get("Accept"))) {
104+
if (preferredRepresentations[0] === HTML_MEDIA_TYPE) {
43105
return withHeaders(response, htmlHeaders, request.method);
44106
}
45107

46108
const markdownAssetPath = toMarkdownAssetPath(url.pathname);
47109
if (!markdownAssetPath) {
48-
return withHeaders(response, htmlHeaders, request.method);
110+
if (preferredRepresentations.includes(HTML_MEDIA_TYPE)) {
111+
return withHeaders(response, htmlHeaders, request.method);
112+
}
113+
114+
return notAcceptableResponse({
115+
acceptHeader,
116+
method: request.method,
117+
response,
118+
});
49119
}
50120

51121
const markdownRequest = new Request(new URL(markdownAssetPath, url), {
@@ -55,7 +125,19 @@ async function negotiateMarkdownResponse({ assetsFetch, request, response }) {
55125
const markdownResponse = await assetsFetch(markdownRequest);
56126

57127
if (!markdownResponse.ok) {
58-
return withHeaders(response, htmlHeaders, request.method);
128+
if (!response.ok) {
129+
return withHeaders(response, htmlHeaders, request.method);
130+
}
131+
132+
if (preferredRepresentations.includes(HTML_MEDIA_TYPE)) {
133+
return withHeaders(response, htmlHeaders, request.method);
134+
}
135+
136+
return notAcceptableResponse({
137+
acceptHeader,
138+
method: request.method,
139+
response,
140+
});
59141
}
60142

61143
const markdownHeaders = new Headers(markdownResponse.headers);

‎src/markdown-negotiation-shared.cjs‎

Lines changed: 142 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,149 @@
1-
const MARKDOWN_CONTENT_TYPE = "text/markdown; charset=utf-8";
1+
const HTML_MEDIA_TYPE = "text/html";
2+
const MARKDOWN_MEDIA_TYPE = "text/markdown";
3+
const MARKDOWN_CONTENT_TYPE = `${MARKDOWN_MEDIA_TYPE}; charset=utf-8`;
24

3-
function acceptsMarkdown(acceptHeader) {
4-
if (!acceptHeader) {
5-
return false;
5+
function parseAcceptEntries(acceptHeader) {
6+
if (!acceptHeader || !acceptHeader.trim()) {
7+
return [];
68
}
79

8-
return acceptHeader.split(",").some((mediaRange) => {
9-
const [type, ...params] = mediaRange.split(";").map((part) => part.trim());
10-
if (type.toLowerCase() !== "text/markdown") {
11-
return false;
10+
return acceptHeader
11+
.split(",")
12+
.map((mediaRange, order) => {
13+
const [type, ...params] = mediaRange.split(";").map((part) => part.trim());
14+
if (!type || !type.includes("/")) {
15+
return null;
16+
}
17+
18+
const qParam = params.find((part) => part.toLowerCase().startsWith("q="));
19+
const quality = qParam ? Number(qParam.slice(2)) : 1;
20+
const q =
21+
Number.isFinite(quality) && quality >= 0 && quality <= 1 ? quality : 0;
22+
23+
return {
24+
order,
25+
q,
26+
type: type.toLowerCase(),
27+
};
28+
})
29+
.filter(Boolean);
30+
}
31+
32+
function getMediaRangeSpecificity(candidateType, mediaRangeType) {
33+
const [candidatePrimaryType, candidateSubtype] = candidateType.split("/");
34+
const [rangePrimaryType, rangeSubtype] = mediaRangeType.split("/");
35+
36+
if (!candidatePrimaryType || !candidateSubtype || !rangePrimaryType || !rangeSubtype) {
37+
return -1;
38+
}
39+
40+
if (rangePrimaryType === "*" && rangeSubtype === "*") {
41+
return 0;
42+
}
43+
44+
if (rangeSubtype === "*" && rangePrimaryType === candidatePrimaryType) {
45+
return 1;
46+
}
47+
48+
if (rangePrimaryType === candidatePrimaryType && rangeSubtype === candidateSubtype) {
49+
return 2;
50+
}
51+
52+
return -1;
53+
}
54+
55+
function getRepresentationRank(representationType, acceptEntries) {
56+
let bestMatch = null;
57+
58+
for (const entry of acceptEntries) {
59+
const specificity = getMediaRangeSpecificity(representationType, entry.type);
60+
if (specificity === -1) {
61+
continue;
1262
}
1363

14-
const qParam = params.find((part) => part.toLowerCase().startsWith("q="));
15-
if (!qParam) {
16-
return true;
64+
if (
65+
!bestMatch ||
66+
specificity > bestMatch.specificity ||
67+
(specificity === bestMatch.specificity && entry.q > bestMatch.q) ||
68+
(specificity === bestMatch.specificity &&
69+
entry.q === bestMatch.q &&
70+
entry.order < bestMatch.order)
71+
) {
72+
bestMatch = {
73+
order: entry.order,
74+
q: entry.q,
75+
specificity,
76+
};
1777
}
78+
}
79+
80+
if (!bestMatch) {
81+
return {
82+
order: Number.POSITIVE_INFINITY,
83+
q: 0,
84+
specificity: -1,
85+
};
86+
}
87+
88+
return bestMatch;
89+
}
90+
91+
function rankRepresentationTypes(
92+
acceptHeader,
93+
availableTypes,
94+
defaultType = availableTypes[0],
95+
) {
96+
const normalizedAvailableTypes = availableTypes.map((type) => type.toLowerCase());
97+
const normalizedDefaultType = defaultType.toLowerCase();
98+
const acceptEntries = parseAcceptEntries(acceptHeader);
99+
100+
if (!acceptEntries.length) {
101+
return normalizedAvailableTypes
102+
.slice()
103+
.sort((a, b) => {
104+
if (a === normalizedDefaultType) {
105+
return -1;
106+
}
107+
108+
if (b === normalizedDefaultType) {
109+
return 1;
110+
}
111+
112+
return normalizedAvailableTypes.indexOf(a) - normalizedAvailableTypes.indexOf(b);
113+
});
114+
}
18115

19-
const quality = Number(qParam.slice(2));
20-
return Number.isFinite(quality) && quality > 0;
21-
});
116+
return normalizedAvailableTypes
117+
.map((type, index) => ({
118+
index,
119+
type,
120+
...getRepresentationRank(type, acceptEntries),
121+
}))
122+
.filter((entry) => entry.q > 0)
123+
.sort((a, b) => {
124+
if (b.q !== a.q) {
125+
return b.q - a.q;
126+
}
127+
128+
if (b.specificity !== a.specificity) {
129+
return b.specificity - a.specificity;
130+
}
131+
132+
if (a.order !== b.order) {
133+
return a.order - b.order;
134+
}
135+
136+
if (a.type === normalizedDefaultType) {
137+
return -1;
138+
}
139+
140+
if (b.type === normalizedDefaultType) {
141+
return 1;
142+
}
143+
144+
return a.index - b.index;
145+
})
146+
.map((entry) => entry.type);
22147
}
23148

24149
function isHtmlContentType(contentType) {
@@ -75,10 +200,12 @@ function appendHeaderValue(headers, name, value) {
75200
}
76201

77202
module.exports = {
203+
HTML_MEDIA_TYPE,
78204
MARKDOWN_CONTENT_TYPE,
79-
acceptsMarkdown,
205+
MARKDOWN_MEDIA_TYPE,
80206
appendHeaderValue,
81207
isHtmlContentType,
82208
isMarkdownPath,
209+
rankRepresentationTypes,
83210
toMarkdownAssetPath,
84211
};

0 commit comments

Comments
 (0)