The site's pages run only their own scripts: a nonce policy, nosniff, a referrer policy and no framing
g1t.sh sent no Content-Security-Policy, X-Content-Type-Options, X-Frame-Options or Referrer-Policy. - Pages: Content-Security-Policy with a fresh nonce per render. Scripts only from the site and the inline ones React and React Router write (ServerRouter, renderToReadableStream, Links, ScrollRestoration and Scripts carry the nonce; app/lib/nonce.ts hands it to the root layout), plus Cloudflare's analytics beacon. Inline styles allowed; images from any HTTPS address, data: and blob:; requests to any HTTPS address (the status summary); no plugins, no frames, base-uri 'self', frame-ancestors 'none'. Off in development, where Vite injects its own. A kept public page keeps the policy it was rendered with. - Every Worker answer: X-Content-Type-Options: nosniff and Referrer-Policy: strict-origin-when-cross-origin unless set; HTML answers X-Frame-Options: DENY (app/lib/page-headers.ts). Static files get the same two from _headers. - Downloads: the ZIP archive, workflow artifacts and log downloads name their file with an ASCII fallback and RFC 6266 filename*, so a ref or name holding quotes or control characters cannot break the header. - Production screenshots: default-src 'none'; sandbox. Checked on a production build (vite preview) in headless Chrome: the landing page, sign-in and sign-up hydrate, every inline script carries the nonce, and no policy violation is reported.
| 3 | 3 | import { isbot } from "isbot"; | |
| 4 | 4 | import { renderToReadableStream } from "react-dom/server"; | |
| 5 | 5 | ||
| 6 | + | import { NonceContext } from "./lib/nonce"; | |
| 7 | + | import { makeNonce, pagePolicy } from "./lib/page-headers"; | |
| 6 | 8 | import { recordHandler } from "./lib/perf.server"; | |
| 7 | 9 | ||
| 8 | 10 | // React Router's own server entry, plus the timing of every loader and | |
| ⋯ | |||
| 47 | 49 | let shellRendered = false; | |
| 48 | 50 | const userAgent = request.headers.get("user-agent"); | |
| 49 | 51 | ||
| 50 | − | const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />, { | |
| 51 | − | signal: AbortSignal.timeout(streamTimeout + 1000), | |
| 52 | − | onError(error: unknown) { | |
| 53 | − | responseStatusCode = 500; | |
| 54 | − | // Errors while streaming after the shell; those in the shell reject | |
| 55 | − | // and are logged by React Router. | |
| 56 | − | if (shellRendered) { | |
| 57 | − | console.error(error); | |
| 58 | − | } | |
| 52 | + | // The page's inline scripts carry this nonce, and its policy allows only | |
| 53 | + | // them (lib/page-headers.ts). Not in development, where Vite adds its own. | |
| 54 | + | const nonce = import.meta.env.DEV ? undefined : makeNonce(); | |
| 55 | + | const body = await renderToReadableStream( | |
| 56 | + | <NonceContext value={nonce}> | |
| 57 | + | <ServerRouter context={routerContext} url={request.url} nonce={nonce} /> | |
| 58 | + | </NonceContext>, | |
| 59 | + | { | |
| 60 | + | nonce, | |
| 61 | + | signal: AbortSignal.timeout(streamTimeout + 1000), | |
| 62 | + | onError(error: unknown) { | |
| 63 | + | responseStatusCode = 500; | |
| 64 | + | // Errors while streaming after the shell; those in the shell reject | |
| 65 | + | // and are logged by React Router. | |
| 66 | + | if (shellRendered) { | |
| 67 | + | console.error(error); | |
| 68 | + | } | |
| 69 | + | }, | |
| 59 | 70 | }, | |
| 60 | − | }); | |
| 71 | + | ); | |
| 61 | 72 | shellRendered = true; | |
| 62 | 73 | ||
| 63 | 74 | // Crawlers get the whole page at once, deferred panels included. | |
| ⋯ | |||
| 66 | 77 | } | |
| 67 | 78 | ||
| 68 | 79 | responseHeaders.set("Content-Type", "text/html"); | |
| 80 | + | if (nonce) responseHeaders.set("Content-Security-Policy", pagePolicy(nonce)); | |
| 69 | 81 | return new Response(body, { | |
| 70 | 82 | headers: responseHeaders, | |
| 71 | 83 | status: responseStatusCode, | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { attachment, hardenRegistryHeaders, NOTHING_RUNS, opensAsDocument } from "./content-safety.ts"; | |
| 4 | + | import { contentDisposition, hardenRegistryHeaders, NOTHING_RUNS, opensAsDocument } from "./content-safety.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("publisher documents a browser would open are downloads", () => { | |
| 7 | 7 | for (const type of [ | |
| ⋯ | |||
| 52 | 52 | }); | |
| 53 | 53 | ||
| 54 | 54 | test("download names keep quotes and control characters out of the header", () => { | |
| 55 | − | assert.equal(attachment("web-main.zip"), `attachment; filename="web-main.zip"; filename*=UTF-8''web-main.zip`); | |
| 56 | − | const sly = attachment('web-a"b\r\nSet-Cookie: x.zip'); | |
| 55 | + | assert.equal(contentDisposition("web-main.zip"), `attachment; filename="web-main.zip"; filename*=UTF-8''web-main.zip`); | |
| 56 | + | const sly = contentDisposition('web-a"b\r\nSet-Cookie: x.zip'); | |
| 57 | 57 | assert.ok(!/[\r\n]/.test(sly)); | |
| 58 | 58 | assert.match(sly, /^attachment; filename="web-a_b__Set-Cookie: x.zip"; filename\*=UTF-8''web-a%22b__Set-Cookie%3A%20x.zip$/); | |
| 59 | − | assert.match(attachment("café.zip"), /filename="caf_.zip"; filename\*=UTF-8''caf%C3%A9.zip$/); | |
| 59 | + | assert.match(contentDisposition("café.zip"), /filename="caf_.zip"; filename\*=UTF-8''caf%C3%A9.zip$/); | |
| 60 | 60 | }); | |
| 55 | 55 | * fallback with anything unsafe replaced, and the exact name as | |
| 56 | 56 | * RFC 6266's `filename*`. | |
| 57 | 57 | */ | |
| 58 | − | export function attachment(filename: string): string { | |
| 58 | + | export function contentDisposition(filename: string): string { | |
| 59 | 59 | const fallback = filename.replace(/[^\x20-\x7e]|["\\%;]/g, "_") || "download"; | |
| 60 | 60 | const exact = encodeURIComponent(filename.replace(/[\x00-\x1f\x7f]/g, "_")).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); | |
| 61 | 61 | return `attachment; filename="${fallback}"; filename*=UTF-8''${exact}`; |
| 1 | 1 | /** Answers for the log downloads: a file to save, or why there is none. */ | |
| 2 | 2 | ||
| 3 | + | import { contentDisposition } from "./content-safety"; | |
| 4 | + | ||
| 3 | 5 | export function refused(status: number, message: string): Response { | |
| 4 | 6 | return new Response(`${message}\n`, { status, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } }); | |
| 5 | 7 | } | |
| ⋯ | |||
| 8 | 10 | return new Response(body, { | |
| 9 | 11 | headers: { | |
| 10 | 12 | "content-type": type, | |
| 11 | − | "content-disposition": `attachment; filename="${file.replace(/["\\\r\n]/g, "")}"`, | |
| 13 | + | "content-disposition": contentDisposition(file), | |
| 12 | 14 | "cache-control": "no-store", | |
| 13 | 15 | }, | |
| 14 | 16 | }); | |
| 1 | + | import { createContext, useContext } from "react"; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * The nonce the page's inline scripts carry, which its Content-Security-Policy | |
| 5 | + | * names (lib/page-headers.ts). Given by entry.server.tsx; in the browser | |
| 6 | + | * there is none, and none is needed: the scripts have already run. | |
| 7 | + | */ | |
| 8 | + | export const NonceContext = createContext<string | undefined>(undefined); | |
| 9 | + | ||
| 10 | + | export function useNonce(): string | undefined { | |
| 11 | + | return useContext(NonceContext); | |
| 12 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { makeNonce, pagePolicy, withSiteHeaders } from "./page-headers.ts"; | |
| 5 | + | ||
| 6 | + | test("a page runs only its own scripts and is never framed", () => { | |
| 7 | + | const nonce = makeNonce(); | |
| 8 | + | assert.match(nonce, /^[A-Za-z0-9+/]{22}==$/); | |
| 9 | + | assert.notEqual(nonce, makeNonce()); | |
| 10 | + | const policy = pagePolicy(nonce); | |
| 11 | + | assert.ok(policy.includes(`script-src 'self' 'nonce-${nonce}'`)); | |
| 12 | + | assert.doesNotMatch(policy, /script-src[^;]*'unsafe-inline'/); | |
| 13 | + | assert.match(policy, /frame-ancestors 'none'/); | |
| 14 | + | assert.match(policy, /object-src 'none'/); | |
| 15 | + | assert.match(policy, /base-uri 'self'/); | |
| 16 | + | // Pictures in a README come from anywhere on HTTPS. | |
| 17 | + | assert.match(policy, /img-src 'self' https: data: blob:/); | |
| 18 | + | }); | |
| 19 | + | ||
| 20 | + | test("every answer gains nosniff and a referrer policy; pages are not framed", () => { | |
| 21 | + | const page = withSiteHeaders(new Response("<p>hi</p>", { headers: { "content-type": "text/html" } })); | |
| 22 | + | assert.equal(page.headers.get("x-content-type-options"), "nosniff"); | |
| 23 | + | assert.equal(page.headers.get("referrer-policy"), "strict-origin-when-cross-origin"); | |
| 24 | + | assert.equal(page.headers.get("x-frame-options"), "DENY"); | |
| 25 | + | ||
| 26 | + | const data = withSiteHeaders(new Response("{}", { headers: { "content-type": "application/json" } })); | |
| 27 | + | assert.equal(data.headers.get("x-content-type-options"), "nosniff"); | |
| 28 | + | assert.equal(data.headers.get("x-frame-options"), null); | |
| 29 | + | ||
| 30 | + | // A redirect's headers are fixed; the answer is copied, status and all. | |
| 31 | + | const moved = withSiteHeaders(Response.redirect("https://docs.g1t.sh/", 301)); | |
| 32 | + | assert.equal(moved.status, 301); | |
| 33 | + | assert.equal(moved.headers.get("location"), "https://docs.g1t.sh/"); | |
| 34 | + | assert.equal(moved.headers.get("referrer-policy"), "strict-origin-when-cross-origin"); | |
| 35 | + | ||
| 36 | + | const own = withSiteHeaders(new Response("", { headers: { "referrer-policy": "no-referrer" } })); | |
| 37 | + | assert.equal(own.headers.get("referrer-policy"), "no-referrer"); | |
| 38 | + | }); |
| 1 | + | /** | |
| 2 | + | * The headers every answer from the site carries, and the policy its pages | |
| 3 | + | * run under. No Workers imports, so it can be tested under Node. | |
| 4 | + | * | |
| 5 | + | * - Nothing is sniffed: a download or a data request is only the type it says. | |
| 6 | + | * - A link to another site sends the origin, never the path. | |
| 7 | + | * - No other site may put g1t's pages in a frame. | |
| 8 | + | * - A page runs only the scripts the site served it: its own files, and the | |
| 9 | + | * inline scripts React and React Router write, each carrying the page's | |
| 10 | + | * nonce. Styles may be inline (highlighting and layout set them); images | |
| 11 | + | * may come from any HTTPS address (pictures in a README); requests may go | |
| 12 | + | * to any HTTPS address (the status page's summary). | |
| 13 | + | */ | |
| 14 | + | ||
| 15 | + | /** A fresh nonce for one page: 128 random bits, base64. */ | |
| 16 | + | export function makeNonce(): string { | |
| 17 | + | const bytes = crypto.getRandomValues(new Uint8Array(16)); | |
| 18 | + | return btoa(String.fromCharCode(...bytes)); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /** The Content-Security-Policy of a page rendered with `nonce`. */ | |
| 22 | + | export function pagePolicy(nonce: string): string { | |
| 23 | + | return [ | |
| 24 | + | "default-src 'self'", | |
| 25 | + | // Cloudflare's Web Analytics beacon, when the zone turns it on. | |
| 26 | + | `script-src 'self' 'nonce-${nonce}' https://static.cloudflareinsights.com`, | |
| 27 | + | "style-src 'self' 'unsafe-inline'", | |
| 28 | + | "img-src 'self' https: data: blob:", | |
| 29 | + | "media-src 'self' https:", | |
| 30 | + | "font-src 'self' data:", | |
| 31 | + | "connect-src 'self' https:", | |
| 32 | + | "frame-src 'none'", | |
| 33 | + | "object-src 'none'", | |
| 34 | + | "base-uri 'self'", | |
| 35 | + | "frame-ancestors 'none'", | |
| 36 | + | ].join("; "); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /** | |
| 40 | + | * The answer with the site's headers added. A header the answer already | |
| 41 | + | * has is kept. An upgrade to a WebSocket is passed on as it is. | |
| 42 | + | */ | |
| 43 | + | export function withSiteHeaders(response: Response): Response { | |
| 44 | + | if (response.status === 101 || (response as Response & { webSocket?: unknown }).webSocket) return response; | |
| 45 | + | // A redirect's headers cannot be changed, so the answer is copied. | |
| 46 | + | const answer = new Response(response.body, response); | |
| 47 | + | const headers = answer.headers; | |
| 48 | + | if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff"); | |
| 49 | + | if (!headers.has("referrer-policy")) headers.set("referrer-policy", "strict-origin-when-cross-origin"); | |
| 50 | + | if (/^text\/html\b/i.test(headers.get("content-type") ?? "") && !headers.has("x-frame-options")) { | |
| 51 | + | headers.set("x-frame-options", "DENY"); | |
| 52 | + | } | |
| 53 | + | return answer; | |
| 54 | + | } |
| 65 | 65 | import { addresses } from "./lib/addresses.server"; | |
| 66 | 66 | import { useSignUpCopy } from "./lib/registration"; | |
| 67 | 67 | import { RELOADED_KEY, reloadFixes } from "./lib/stale-build"; | |
| 68 | + | import { useNonce } from "./lib/nonce"; | |
| 68 | 69 | ||
| 69 | 70 | ||
| 70 | 71 | export const links: Route.LinksFunction = () => [ | |
| ⋯ | |||
| 462 | 463 | let lastRoot: Awaited<ReturnType<typeof loader>> | undefined; | |
| 463 | 464 | ||
| 464 | 465 | export function Layout({ children }: { children: React.ReactNode }) { | |
| 466 | + | const nonce = useNonce(); | |
| 465 | 467 | // Undefined when the root loader itself failed. | |
| 466 | 468 | const loaded = useRouteLoaderData<typeof loader>("root"); | |
| 467 | 469 | const inBrowser = typeof document !== "undefined"; | |
| ⋯ | |||
| 517 | 519 | connection paints sooner (docs/research/css-shipping.md). */} | |
| 518 | 520 | <link rel="stylesheet" href={appCss} precedence="default" /> | |
| 519 | 521 | <Meta /> | |
| 520 | − | <Links /> | |
| 522 | + | <Links nonce={nonce} /> | |
| 521 | 523 | </head> | |
| 522 | 524 | <body className="flex min-h-screen flex-col"> | |
| 523 | 525 | {/* The first stop for the keyboard: past the menus, to the page. */} | |
| ⋯ | |||
| 542 | 544 | <SiteFooter user={user} /> | |
| 543 | 545 | </> | |
| 544 | 546 | )} | |
| 545 | − | <ScrollRestoration /> | |
| 546 | − | <Scripts /> | |
| 547 | + | <ScrollRestoration nonce={nonce} /> | |
| 548 | + | <Scripts nonce={nonce} /> | |
| 547 | 549 | </body> | |
| 548 | 550 | </html> | |
| 549 | 551 | ); | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/actions-artifact"; | |
| 4 | 4 | import { addresses } from "../../lib/addresses.server"; | |
| 5 | + | import { contentDisposition } from "../../lib/content-safety"; | |
| 5 | 6 | import { readArtifact } from "../../lib/artifacts.server"; | |
| 6 | 7 | import { actions } from "../../lib/services.server"; | |
| 7 | 8 | import { getViewer } from "../../lib/session.server"; | |
| ⋯ | |||
| 25 | 26 | return new Response(bytes.buffer as ArrayBuffer, { | |
| 26 | 27 | headers: { | |
| 27 | 28 | "content-type": "application/gzip", | |
| 28 | − | "content-disposition": `attachment; filename="${params.name.replace(/"/g, "")}.tar.gz"`, | |
| 29 | + | "content-disposition": contentDisposition(`${params.name}.tar.gz`), | |
| 29 | 30 | }, | |
| 30 | 31 | }); | |
| 31 | 32 | } | |
| 7 | 7 | import type { Route } from "./+types/archive"; | |
| 8 | 8 | import { cloneUrl } from "../../lib/addresses"; | |
| 9 | 9 | import { addresses } from "../../lib/addresses.server"; | |
| 10 | + | import { contentDisposition } from "../../lib/content-safety"; | |
| 10 | 11 | import { repos } from "../../lib/services.server"; | |
| 11 | 12 | import { getViewer } from "../../lib/session.server"; | |
| 12 | 13 | import { zip } from "../../lib/zip"; | |
| ⋯ | |||
| 61 | 62 | return new Response(archive, { | |
| 62 | 63 | headers: { | |
| 63 | 64 | "content-type": "application/zip", | |
| 64 | − | "content-disposition": `attachment; filename="${label}.zip"`, | |
| 65 | + | // A ref may hold quotes; the header never does. | |
| 66 | + | "content-disposition": contentDisposition(`${label}.zip`), | |
| 65 | 67 | // A commit's files never change; a branch's do. | |
| 66 | 68 | "cache-control": /^[0-9a-f]{40}$/.test(ref) ? "private, max-age=31536000, immutable" : "private, no-cache", | |
| 67 | 69 | }, | |
| 45 | 45 | "content-type": shot.contentType, | |
| 46 | 46 | "cache-control": current ? LONG : BRIEF, | |
| 47 | 47 | "x-content-type-options": "nosniff", | |
| 48 | + | // A picture of someone's app: shown, never run. | |
| 49 | + | "content-security-policy": "default-src 'none'; sandbox", | |
| 48 | 50 | "last-modified": new Date(shot.capturedAt).toUTCString(), | |
| 49 | 51 | }, | |
| 50 | 52 | }); |
| 4 | 4 | # kept for good. That includes the typefaces. | |
| 5 | 5 | /assets/* | |
| 6 | 6 | Cache-Control: public, max-age=31536000, immutable | |
| 7 | + | ||
| 8 | + | # Every static file: only the type it says, and a referrer of the origin | |
| 9 | + | # alone, as the Worker's own answers (app/lib/page-headers.ts). | |
| 10 | + | /* | |
| 11 | + | X-Content-Type-Options: nosniff | |
| 12 | + | Referrer-Policy: strict-origin-when-cross-origin |
| 3 | 3 | import { identityClient, isNamespaceShaped } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | import { hardenRegistryHeaders } from "../app/lib/content-safety"; | |
| 6 | + | import { withSiteHeaders } from "../app/lib/page-headers"; | |
| 6 | 7 | import { finishResponse, withRequestPerf } from "../app/lib/perf.server"; | |
| 7 | 8 | import { goImport } from "../app/lib/go-get"; | |
| 8 | 9 | import { repositoryOfPage, stillPublic } from "../app/lib/public-cache"; | |
| ⋯ | |||
| 33 | 34 | ||
| 34 | 35 | export default { | |
| 35 | 36 | async fetch(request, env, ctx) { | |
| 36 | − | const { pathname } = new URL(request.url); | |
| 37 | − | // Git over HTTPS shares this hostname but belongs to the repos service. | |
| 38 | − | // Its answer goes back to the git client as it is: a repository under a | |
| 39 | − | // renamed workspace's old name answers with a 301, which git follows and | |
| 40 | − | // must see, so the redirect is never followed here. | |
| 41 | − | // The container registry (`docker login g1t.sh`) and the npm registry | |
| 42 | − | // (`g1t.sh/-/npm/`) are the packages | |
| 43 | − | // service's, handed over the same way. | |
| 44 | − | // `go get g1t.sh/<workspace>/<repo>`: where its code is, from the | |
| 45 | − | // address alone, so it costs nothing and caches. | |
| 46 | − | const go = request.method === "GET" ? goImport(new URL(request.url)) : null; | |
| 47 | − | if (go) { | |
| 48 | − | return new Response(go, { | |
| 49 | − | headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" }, | |
| 50 | − | }); | |
| 51 | − | } | |
| 52 | − | const service = servicePath(pathname); | |
| 53 | − | if (service === "git") { | |
| 54 | − | return proxyGit(env, request); | |
| 55 | − | } | |
| 56 | − | if (service === "packages") { | |
| 57 | − | return proxyPackages(env, request); | |
| 58 | − | } | |
| 59 | − | const avatar = AVATAR_PATH.exec(pathname); | |
| 60 | − | if (avatar) { | |
| 61 | − | return serveAvatar(env, ctx, request, avatar[1]); | |
| 62 | − | } | |
| 63 | − | // The documentation is its own site. | |
| 64 | − | if (pathname === "/docs" || pathname.startsWith("/docs/")) { | |
| 65 | − | const page = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; | |
| 66 | − | const target = MOVED_DOCS[page] ?? "/"; | |
| 67 | − | return Response.redirect(DOCS + target, 301); | |
| 68 | − | } | |
| 69 | − | // Every page and data request says where its time went (Server-Timing) | |
| 70 | − | // and keeps the reader's D1 bookmarks (app/lib/perf.server.ts). | |
| 71 | − | const render = () => withRequestPerf(request, async () => finishResponse(request, await requestHandler(request))); | |
| 72 | − | if (anonymousPage(request, pathname)) return servePublic(env, request, ctx, render); | |
| 73 | − | return render(); | |
| 37 | + | // Every answer: no sniffing, a referrer of the origin alone, and no | |
| 38 | + | // framing of pages (app/lib/page-headers.ts). | |
| 39 | + | return withSiteHeaders(await site(request, env, ctx)); | |
| 74 | 40 | }, | |
| 75 | 41 | } satisfies ExportedHandler<Env>; | |
| 76 | 42 | ||
| 43 | + | async function site(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | |
| 44 | + | const { pathname } = new URL(request.url); | |
| 45 | + | // Git over HTTPS shares this hostname but belongs to the repos service. | |
| 46 | + | // Its answer goes back to the git client as it is: a repository under a | |
| 47 | + | // renamed workspace's old name answers with a 301, which git follows and | |
| 48 | + | // must see, so the redirect is never followed here. | |
| 49 | + | // The container registry (`docker login g1t.sh`) and the npm registry | |
| 50 | + | // (`g1t.sh/-/npm/`) are the packages | |
| 51 | + | // service's, handed over the same way. | |
| 52 | + | // `go get g1t.sh/<workspace>/<repo>`: where its code is, from the | |
| 53 | + | // address alone, so it costs nothing and caches. | |
| 54 | + | const go = request.method === "GET" ? goImport(new URL(request.url)) : null; | |
| 55 | + | if (go) { | |
| 56 | + | return new Response(go, { | |
| 57 | + | headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" }, | |
| 58 | + | }); | |
| 59 | + | } | |
| 60 | + | const service = servicePath(pathname); | |
| 61 | + | if (service === "git") { | |
| 62 | + | return proxyGit(env, request); | |
| 63 | + | } | |
| 64 | + | if (service === "packages") { | |
| 65 | + | return proxyPackages(env, request); | |
| 66 | + | } | |
| 67 | + | const avatar = AVATAR_PATH.exec(pathname); | |
| 68 | + | if (avatar) { | |
| 69 | + | return serveAvatar(env, ctx, request, avatar[1]); | |
| 70 | + | } | |
| 71 | + | // The documentation is its own site. | |
| 72 | + | if (pathname === "/docs" || pathname.startsWith("/docs/")) { | |
| 73 | + | const page = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; | |
| 74 | + | const target = MOVED_DOCS[page] ?? "/"; | |
| 75 | + | return Response.redirect(DOCS + target, 301); | |
| 76 | + | } | |
| 77 | + | // Every page and data request says where its time went (Server-Timing) | |
| 78 | + | // and keeps the reader's D1 bookmarks (app/lib/perf.server.ts). | |
| 79 | + | const render = () => withRequestPerf(request, async () => finishResponse(request, await requestHandler(request))); | |
| 80 | + | if (anonymousPage(request, pathname)) return servePublic(env, request, ctx, render); | |
| 81 | + | return render(); | |
| 82 | + | } | |
| 83 | + | ||
| 77 | 84 | /** | |
| 78 | 85 | * Public pages as someone signed out sees them: the same for every such | |
| 79 | 86 | * visitor, so kept in this data centre's cache. Reserved first segments | |