/** * CloudFront Function, VIEWER REQUEST, on the DEFAULT behaviour and on `*.pdf`. * Not on `/api/*` — see the rule below, which is the one that matters. * * `*.pdf` has it because this function is NOT a no-op on file paths: it * normalises `\` to `/` and collapses a leading `//` run BEFORE the extension * test, and 301s when that changed anything. Measured live 2026-09-03: * `//pouya-lajevardi-bio.pdf` returns 301. Dropping the association there hands * S3 the doubled key and returns 404 instead. * * ⚠️ THE SITE DOES NOT WORK WITHOUT THIS. `astro.config.mjs` sets * `trailingSlash: 'always'` and `build.format: 'directory'`, so every route is * `/index.html`. CloudFront forwards the viewer path to the S3 REST origin * unchanged, S3 has no key `about/`, and the request fails. Measured on the live * distribution 2026-09-01, before this function existed: `/about/` and * `/definitely-not-a-page/` both returned **403 with an 111-byte * `application/xml` body** — S3's AccessDenied, served raw to the reader. Only * `/` worked, via the distribution's default root object. That is 22 of the 23 * pages. * * ⚠️ DO NOT ASSOCIATE IT WITH THE `/api/*` BEHAVIOUR. The intake path * `/api/intake` has no extension and no trailing slash, so the redirect branch * below would answer a form POST with a 301 — and a 301 turns a POST into a GET, * which would lose the submission body silently. The association is per * behaviour and `/api/*` gets none. * * Two rules, and the second is a `docs/04` requirement rather than a nicety: * * /about/ -> rewrite to /about/index.html (the origin has that key) * /about -> 301 to /about/ (one canonical URL per page) * * Anything with a file extension in its last segment is left alone — * `robots.txt`, `sitemap-0.xml`, `/_astro/*`, `/fonts/*`, `/og/*.jpg`, * `favicon.ico`, `pouya-lajevardi-bio.pdf`, and `404.html` itself. * * Written to the `cloudfront-js-2.0` runtime and deliberately conservative: no * arrow functions, no `String.prototype.endsWith`, no template literals. The * runtime supports more than this; a viewer-request function runs on every * request to the site and is the wrong place to be clever. */ /* The header-injection surface, and nothing else: C0 controls, DEL, space, and WHATWG's query percent-encode set (`"`, `#`, `<`, `>`). `#` is in because it changes the STRUCTURE of the Location — left in, `?a=x#&b=y` drops `&b=y` into a fragment. `| ^ ` { }` are NOT in, and must not be added: browsers send them raw and `|` is routine in tracking values. Strip rather than encode — these values arrive percent-encoded, so encoding again makes `%20` into `%2520`. */ function safe(part) { // eslint-disable-next-line no-control-regex return String(part).replace(/[\u0000-\u0020\u007f"<>#]/g, ''); } function handler(event) { var request = event.request; /* ⚠️ NORMALISE, THEN REDIRECT IF ANYTHING CHANGED. Leading `//` and `\` are collapsed because CloudFront forwards duplicate slashes verbatim (it resolves dot-segments; it does not collapse `//`) and `Location: //host/x` is a network-path reference that REPLACES THE AUTHORITY — RFC 3986 s4.2. `\` does the same, because the URL Standard maps it to `/` in special schemes. Redirect rather than rewrite, or `//about/` serves the About page at a second URL with a 200. Only the leading run: an interior `//` is a key that does not exist. */ var uri = request.uri.replace(/\\/g, '/').replace(/^\/+/, '/'); var normalised = uri !== request.uri; var lastSlash = uri.lastIndexOf('/'); var lastSegment = uri.substring(lastSlash + 1); // A file, not a route. if (lastSegment.indexOf('.') !== -1) { if (normalised) return moved(uri, request); return request; } // A directory-style route: hand the origin the key it actually holds. if (lastSegment === '') { if (normalised) return moved(uri, request); request.uri = uri + 'index.html'; return request; } /* Extensionless and no trailing slash. Redirect rather than rewrite, so the page has ONE address: serving it at both would put two indexable URLs on the same content, which `docs/04` treats as its primary concern. */ return moved(uri + '/', request); } /** * 301 to a path on this origin, carrying the query string. `location` is always * built from an already-normalised path, which is what keeps it same-origin. */ function moved(path, request) { var qs = ''; var names = Object.keys(request.querystring); for (var i = 0; i < names.length; i++) { var name = names[i]; var value = request.querystring[name]; if (value.multiValue) { for (var j = 0; j < value.multiValue.length; j++) { qs += (qs === '' ? '' : '&') + safe(name) + '=' + safe(value.multiValue[j].value); } } else { /* Always `name=value`, so `?ref` and `?ref=` normalise to one form rather than the function guessing which the viewer meant. */ qs += (qs === '' ? '' : '&') + safe(name) + '=' + safe(value.value); } } return { statusCode: 301, statusDescription: 'Moved Permanently', headers: { location: { value: path + (qs === '' ? '' : '?' + qs) }, 'cache-control': { value: 'public, max-age=0, must-revalidate' }, }, }; }