1// Alchemy modifications are licensed under Apache-2.0.
2// This file includes third-party code; see /THIRD_PARTY_LICENSES.md.
3/**
4 * CloudFront Function injection code ported from SST's Router component.
5 * These are JavaScript code strings that get injected into CloudFront Function handlers.
6 */
7
8/**
9 * Host-based 301 handling injected at the very top of a generated
10 * viewer-request handler (before route matching). Redirects requests whose
11 * `Host` is one of the configured redirect hostnames — and, when
12 * `cloudfrontUrl: false`, requests arriving via the distribution's default
13 * `*.cloudfront.net` domain (the default domain is not knowable inside its
14 * own function config before the distribution exists, but only requests
15 * served via the default domain ever carry a `.cloudfront.net` Host;
16 * alternate domain names never do) — to the canonical hostname with path
17 * and query preserved.
18 *
19 * Relies on `buildHostRedirectResponse` declared by
20 * {@link CF_ROUTER_INJECTION} (function declarations hoist within the
21 * handler). Returns an empty string when there is nothing to redirect.
22 */
23export const buildHostRedirectInjection = ({
24 to,
25 hosts,
26 cloudfrontDefault,
27}: {
28 /** Canonical hostname redirected to. */
29 to: string | undefined;
30 /** Exact redirect hostnames. */
31 hosts: string[];
32 /** Also redirect the distribution's default `*.cloudfront.net` domain. */
33 cloudfrontDefault: boolean;
34}): string => {
35 if (!to || (hosts.length === 0 && !cloudfrontDefault)) return "";
36 const conditions = [
37 ...(hosts.length > 0
38 ? [`${JSON.stringify(hosts)}.indexOf(redirectHost) !== -1`]
39 : []),
40 ...(cloudfrontDefault ? [`redirectHost.endsWith(".cloudfront.net")`] : []),
41 ];
42 return `
43 var redirectHost = event.request.headers.host.value;
44 if (${conditions.join(" || ")}) {
45 return buildHostRedirectResponse(${JSON.stringify(to)});
46 }`;
47};
48
49const CLOUDFRONT_FUNCTION_SAFE_HEADER_LIMIT = 10240 - 512;
50
51export const CF_ROUTER_INJECTION = `
52async function routeSite(kvNamespace, metadata) {
53 if (metadata.redirect && metadata.redirect.hosts.indexOf(event.request.headers.host.value) !== -1) {
54 return buildHostRedirectResponse(metadata.redirect.to);
55 }
56
57 var baselessUri = metadata.base
58 ? event.request.uri.replace(metadata.base, "")
59 : event.request.uri;
60
61 try {
62 var u = decodeURIComponent(baselessUri);
63 var postfixes = u.endsWith("/")
64 ? ["index.html"]
65 : ["", ".html", "/index.html"];
66 var v = await Promise.any(postfixes.map(function(p) { return cf.kvs().get(kvNamespace + ":" + u + p).then(function() { return p; }); }));
67 event.request.uri = metadata.s3.dir + event.request.uri + v;
68 setS3Origin(metadata.s3.domain);
69 return;
70 } catch (e) {}
71
72 if (metadata.s3 && metadata.s3.routes) {
73 for (var i=0, l=metadata.s3.routes.length; i<l; i++) {
74 var route = metadata.s3.routes[i];
75 if (baselessUri.startsWith(route)) {
76 event.request.uri = metadata.s3.dir + event.request.uri;
77 if (event.request.uri.endsWith("/")) {
78 event.request.uri += "index.html";
79 } else if (!event.request.uri.split("/").pop().includes(".")) {
80 event.request.uri += "/index.html";
81 }
82 setS3Origin(metadata.s3.domain);
83 return;
84 }
85 }
86 }
87
88 if (metadata.custom404 && !metadata.errorResponseCode) {
89 event.request.uri = metadata.s3.dir + (metadata.base ? metadata.base : "") + metadata.custom404;
90 setS3Origin(metadata.s3.domain);
91 return;
92 }
93
94 if (metadata.s3 && !metadata.servers) {
95 event.request.uri = metadata.s3.dir + event.request.uri;
96 setS3Origin(metadata.s3.domain);
97 return;
98 }
99
100 if (metadata.image && baselessUri.startsWith(metadata.image.route)) {
101 setForwardedHost();
102 if (isRequestHeaderTooLarge()) return buildOversizedHeadersResponse();
103 setUrlOrigin(metadata.image.host, metadata.image.originAccessControlConfig ? { originAccessControlConfig: metadata.image.originAccessControlConfig } : undefined);
104 return;
105 }
106
107 if (metadata.servers) {
108 setForwardedHost();
109 for (var key in event.request.querystring) {
110 if (key.includes("/")) {
111 event.request.querystring[encodeURIComponent(key)] = event.request.querystring[key];
112 delete event.request.querystring[key];
113 }
114 }
115 if (isRequestHeaderTooLarge()) return buildOversizedHeadersResponse();
116 setUrlOrigin(findNearestServer(metadata.servers), metadata.origin);
117 }
118
119 function findNearestServer(servers) {
120 if (servers.length === 1) return servers[0][0];
121 var h = event.request.headers;
122 var lat = h["cloudfront-viewer-latitude"] && h["cloudfront-viewer-latitude"].value;
123 var lon = h["cloudfront-viewer-longitude"] && h["cloudfront-viewer-longitude"].value;
124 if (!lat || !lon) return servers[0][0];
125 return servers.map(function(s) { return { distance: haversineDistance(lat, lon, s[1], s[2]), host: s[0] }; }).sort(function(a,b) { return a.distance - b.distance; })[0].host;
126 }
127
128 function haversineDistance(lat1, lon1, lat2, lon2) {
129 var toRad = function(a) { return a * Math.PI / 180; };
130 var dLat = toRad(lat2 - lat1);
131 var dLon = toRad(lon2 - lon1);
132 var a = Math.sin(dLat/2)*Math.sin(dLat/2) + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)*Math.sin(dLon/2);
133 return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
134 }
135
136 function isRequestHeaderTooLarge() {
137 return getRequestHeaderSize() > ${CLOUDFRONT_FUNCTION_SAFE_HEADER_LIMIT};
138 }
139
140 function buildOversizedHeadersResponse() {
141 return {
142 statusCode: 431,
143 statusDescription: "Request Header Fields Too Large",
144 headers: { "cache-control": { value: "no-store" }, "content-type": { value: "text/plain; charset=utf-8" } },
145 body: { encoding: "text", data: "Request headers are too large. Reduce cookie size and try again." },
146 };
147 }
148
149 function getRequestHeaderSize() {
150 var size = 0;
151 for (var key in event.request.headers) {
152 var header = event.request.headers[key];
153 if (header.multiValue) {
154 for (var i=0; i<header.multiValue.length; i++) size += key.length + header.multiValue[i].value.length + 4;
155 } else if (header.value) {
156 size += key.length + header.value.length + 4;
157 }
158 }
159 var cookies = [];
160 for (var key in event.request.cookies) {
161 var cookie = event.request.cookies[key];
162 if (cookie.multiValue) {
163 for (var i=0; i<cookie.multiValue.length; i++) cookies.push(key + "=" + cookie.multiValue[i].value);
164 } else {
165 cookies.push(key + "=" + cookie.value);
166 }
167 }
168 if (cookies.length) size += 10 + cookies.join("; ").length;
169 return size;
170 }
171}
172
173function setForwardedHost() {
174 event.request.headers["x-forwarded-host"] = event.request.headers.host;
175}
176
177function serializeQuerystring() {
178 var parts = [];
179 for (var key in event.request.querystring) {
180 var q = event.request.querystring[key];
181 if (q.multiValue) {
182 for (var i = 0; i < q.multiValue.length; i++) parts.push(key + "=" + q.multiValue[i].value);
183 } else {
184 parts.push(q.value === "" ? key : key + "=" + q.value);
185 }
186 }
187 return parts.length ? "?" + parts.join("&") : "";
188}
189
190function buildHostRedirectResponse(toHost) {
191 return {
192 statusCode: 301,
193 statusDescription: "Moved Permanently",
194 headers: {
195 location: { value: "https://" + toHost + event.request.uri + serializeQuerystring() },
196 "cache-control": { value: "no-store" },
197 },
198 };
199}
200
201function setUrlOrigin(urlHost, override) {
202 setForwardedHost();
203 var origin = {
204 domainName: urlHost,
205 customOriginConfig: {
206 port: 443,
207 protocol: "https",
208 sslProtocols: ["TLSv1.2"],
209 },
210 originAccessControlConfig: {
211 enabled: false,
212 }
213 };
214 override = override || {};
215 if (override.protocol === "http") delete origin.customOriginConfig;
216 if (override.connectionAttempts) origin.connectionAttempts = override.connectionAttempts;
217 if (override.timeouts) origin.timeouts = override.timeouts;
218 if (override.originAccessControlConfig) origin.originAccessControlConfig = override.originAccessControlConfig;
219 cf.updateRequestOrigin(origin);
220}
221
222function setS3Origin(s3Domain, override) {
223 delete event.request.headers["Cookies"];
224 delete event.request.headers["cookies"];
225 delete event.request.cookies;
226 var origin = {
227 domainName: s3Domain,
228 originAccessControlConfig: {
229 enabled: true,
230 signingBehavior: "always",
231 signingProtocol: "sigv4",
232 originType: "s3",
233 }
234 };
235 override = override || {};
236 if (override.connectionAttempts) origin.connectionAttempts = override.connectionAttempts;
237 if (override.timeouts) origin.timeouts = override.timeouts;
238 cf.updateRequestOrigin(origin);
239}`;