Render the widget on the contact, event registration and both application forms, and verify the token with Siteverify in each action before anything is written to the CMS. Verification fails closed. The check is off unless both TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set. Allow challenges.cloudflare.com in script-src and frame-src. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
160 lines
5.8 KiB
TypeScript
160 lines
5.8 KiB
TypeScript
/* ============================================================================
|
|
SERVER ENTRY
|
|
----------------------------------------------------------------------------
|
|
The stock React Router streaming entry plus two SHARP-ER additions:
|
|
|
|
- `Content-Language` is stamped from the URL's locale prefix, so the
|
|
header agrees with <html lang> that root.tsx renders. `Vary` is NOT set
|
|
globally: every page URL carries its own locale, so only the bare "/"
|
|
redirect varies on Accept-Language and it sets the header itself.
|
|
- `handleError` logs server errors without the noise of client aborts.
|
|
========================================================================= */
|
|
|
|
import { PassThrough } from "node:stream";
|
|
|
|
import type { EntryContext, RouterContextProvider } from "react-router";
|
|
import { createReadableStreamFromReadable } from "@react-router/node";
|
|
import { ServerRouter } from "react-router";
|
|
import { isbot } from "isbot";
|
|
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
|
import { renderToPipeableStream } from "react-dom/server";
|
|
|
|
import { DEFAULT_LOCALE, getLocaleFromPathname } from "~/i18n/config";
|
|
import { getServerEnv } from "~/lib/env.server";
|
|
|
|
export const streamTimeout = 5_000;
|
|
|
|
/** The locale a URL addresses, for the `Content-Language` response header. */
|
|
function contentLanguage(request: Request): string {
|
|
try {
|
|
return getLocaleFromPathname(new URL(request.url).pathname) ?? DEFAULT_LOCALE;
|
|
} catch {
|
|
return DEFAULT_LOCALE;
|
|
}
|
|
}
|
|
|
|
function readCspNonce(routerContext: EntryContext): string | undefined {
|
|
const rootData = routerContext.staticHandlerContext.loaderData["root"] as
|
|
| { nonce?: unknown }
|
|
| undefined;
|
|
return typeof rootData?.nonce === "string" ? rootData.nonce : undefined;
|
|
}
|
|
|
|
function contentSecurityPolicy(nonce: string): string {
|
|
const { STRAPI_URL, isProduction } = getServerEnv();
|
|
const mediaOrigin = new URL(STRAPI_URL).origin;
|
|
const directives = [
|
|
"default-src 'self'",
|
|
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com https://challenges.cloudflare.com`,
|
|
"script-src-attr 'none'",
|
|
// The Turnstile widget on the public forms renders in Cloudflare's iframe.
|
|
"frame-src https://challenges.cloudflare.com",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
`img-src 'self' ${mediaOrigin} https://www.google-analytics.com data: blob:`,
|
|
`media-src 'self' ${mediaOrigin} data: blob:`,
|
|
"font-src 'self' data:",
|
|
"connect-src 'self' https://www.googletagmanager.com https://www.google-analytics.com https://region1.google-analytics.com",
|
|
"object-src 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
"frame-ancestors 'none'",
|
|
...(isProduction ? ["upgrade-insecure-requests"] : []),
|
|
];
|
|
return directives.join("; ");
|
|
}
|
|
|
|
export default function handleRequest(
|
|
request: Request,
|
|
responseStatusCode: number,
|
|
responseHeaders: Headers,
|
|
routerContext: EntryContext,
|
|
loadContext: RouterContextProvider,
|
|
) {
|
|
const nonce = readCspNonce(routerContext);
|
|
if (nonce) {
|
|
responseHeaders.set("Content-Security-Policy", contentSecurityPolicy(nonce));
|
|
}
|
|
|
|
// https://httpwg.org/specs/rfc9110.html#HEAD
|
|
if (request.method.toUpperCase() === "HEAD") {
|
|
return new Response(null, {
|
|
status: responseStatusCode,
|
|
headers: responseHeaders,
|
|
});
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let shellRendered = false;
|
|
let userAgent = request.headers.get("user-agent");
|
|
|
|
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
|
|
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
|
|
let readyOption: keyof RenderToPipeableStreamOptions =
|
|
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
|
|
? "onAllReady"
|
|
: "onShellReady";
|
|
|
|
// Abort the rendering stream after the `streamTimeout` so it has time to
|
|
// flush down the rejected boundaries
|
|
let timeoutId: ReturnType<typeof setTimeout> | undefined = setTimeout(
|
|
() => abort(),
|
|
streamTimeout + 1000,
|
|
);
|
|
|
|
const { pipe, abort } = renderToPipeableStream(
|
|
<ServerRouter context={routerContext} url={request.url} nonce={nonce} />,
|
|
{
|
|
nonce,
|
|
[readyOption]() {
|
|
shellRendered = true;
|
|
const body = new PassThrough({
|
|
final(callback) {
|
|
// Clear the timeout to prevent retaining the closure and memory leak
|
|
clearTimeout(timeoutId);
|
|
timeoutId = undefined;
|
|
callback();
|
|
},
|
|
});
|
|
const stream = createReadableStreamFromReadable(body);
|
|
|
|
responseHeaders.set("Content-Type", "text/html");
|
|
responseHeaders.set("Content-Language", contentLanguage(request));
|
|
|
|
pipe(body);
|
|
|
|
resolve(
|
|
new Response(stream, {
|
|
headers: responseHeaders,
|
|
status: responseStatusCode,
|
|
}),
|
|
);
|
|
},
|
|
onShellError(error: unknown) {
|
|
reject(error);
|
|
},
|
|
onError(error: unknown) {
|
|
responseStatusCode = 500;
|
|
// Log streaming rendering errors from inside the shell. Don't log
|
|
// errors encountered during initial shell rendering since they'll
|
|
// reject and get logged in handleDocumentRequest.
|
|
if (shellRendered) {
|
|
console.error(error);
|
|
}
|
|
},
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Central server-side error logging. A request the client walked away from is
|
|
* not an application error, so it is not logged as one.
|
|
*/
|
|
export function handleError(
|
|
error: unknown,
|
|
{ request }: { request: Request },
|
|
): void {
|
|
if (request.signal.aborted) return;
|
|
console.error(`[sharp-er] ${request.method} ${request.url}`, error);
|
|
}
|