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>
159 lines
5.1 KiB
TypeScript
159 lines
5.1 KiB
TypeScript
/* ============================================================================
|
|
SERVER ENVIRONMENT
|
|
----------------------------------------------------------------------------
|
|
The `.server` suffix is enforced by the React Router Vite plugin: this
|
|
module is stripped from the browser bundle, and importing it from component
|
|
code fails the build rather than leaking STRAPI_TOKEN to the client.
|
|
|
|
Validation is lazy and memoised. Reading it at module load would crash the
|
|
whole server on a typo in one variable; reading it on first use lets the
|
|
error surface as an ordinary 500 with every problem listed at once.
|
|
========================================================================= */
|
|
|
|
export type NodeEnv = "development" | "production" | "test";
|
|
|
|
export interface ServerEnv {
|
|
readonly NODE_ENV: NodeEnv;
|
|
/** Strapi REST origin, no trailing slash. Default http://localhost:1337 */
|
|
readonly STRAPI_URL: string;
|
|
/** Optional API token. Sent as a bearer token when present. */
|
|
readonly STRAPI_TOKEN?: string;
|
|
/** Public origin of this site, no trailing slash. Used for canonical URLs. */
|
|
readonly SITE_URL: string;
|
|
/**
|
|
* Cloudflare Turnstile keys. Set together or not at all: the check on the
|
|
* public forms is on only when BOTH are present, so a half-configured deploy
|
|
* leaves the forms unprotected rather than rejecting every submission.
|
|
*/
|
|
readonly TURNSTILE_SITE_KEY?: string;
|
|
readonly TURNSTILE_SECRET_KEY?: string;
|
|
readonly isProduction: boolean;
|
|
readonly isDevelopment: boolean;
|
|
}
|
|
|
|
/** Thrown when one or more variables are missing or malformed. */
|
|
export class EnvError extends Error {
|
|
override readonly name = "EnvError";
|
|
readonly issues: readonly string[];
|
|
|
|
constructor(issues: readonly string[]) {
|
|
super(`Invalid server environment:\n - ${issues.join("\n - ")}`);
|
|
this.issues = issues;
|
|
}
|
|
}
|
|
|
|
const DEFAULTS = {
|
|
STRAPI_URL: "http://localhost:1337",
|
|
SITE_URL: "https://sharper.ly",
|
|
} as const;
|
|
|
|
function trimTrailingSlash(value: string): string {
|
|
return value.replace(/\/+$/, "");
|
|
}
|
|
|
|
function readOrigin(
|
|
name: string,
|
|
raw: string | undefined,
|
|
fallback: string,
|
|
issues: string[],
|
|
): string {
|
|
const value = raw?.trim() || fallback;
|
|
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
issues.push(`${name} must be an http(s) URL, received "${value}"`);
|
|
return trimTrailingSlash(fallback);
|
|
}
|
|
return trimTrailingSlash(value);
|
|
} catch {
|
|
issues.push(`${name} must be an absolute URL, received "${value}"`);
|
|
return trimTrailingSlash(fallback);
|
|
}
|
|
}
|
|
|
|
function readNodeEnv(raw: string | undefined): NodeEnv {
|
|
return raw === "production" || raw === "test" ? raw : "development";
|
|
}
|
|
|
|
let cached: ServerEnv | undefined;
|
|
|
|
/** The validated environment. Memoised after the first successful read. */
|
|
export function getServerEnv(): ServerEnv {
|
|
if (cached) return cached;
|
|
|
|
const source = process.env;
|
|
const issues: string[] = [];
|
|
|
|
const NODE_ENV = readNodeEnv(source.NODE_ENV);
|
|
const STRAPI_URL = readOrigin(
|
|
"STRAPI_URL",
|
|
source.STRAPI_URL,
|
|
DEFAULTS.STRAPI_URL,
|
|
issues,
|
|
);
|
|
const SITE_URL = readOrigin(
|
|
"SITE_URL",
|
|
source.SITE_URL,
|
|
DEFAULTS.SITE_URL,
|
|
issues,
|
|
);
|
|
const token = source.STRAPI_TOKEN?.trim();
|
|
const turnstileSiteKey = source.TURNSTILE_SITE_KEY?.trim();
|
|
const turnstileSecretKey = source.TURNSTILE_SECRET_KEY?.trim();
|
|
|
|
if (issues.length > 0) throw new EnvError(issues);
|
|
|
|
/* Not an EnvError: a missing bot check is no reason to take every page
|
|
down. It is said out loud once, because the forms are now unguarded. */
|
|
if (Boolean(turnstileSiteKey) !== Boolean(turnstileSecretKey)) {
|
|
console.warn(
|
|
"[env] Only one of TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY is set; Turnstile is off.",
|
|
);
|
|
}
|
|
|
|
cached = {
|
|
NODE_ENV,
|
|
STRAPI_URL,
|
|
SITE_URL,
|
|
...(token ? { STRAPI_TOKEN: token } : {}),
|
|
...(turnstileSiteKey && turnstileSecretKey
|
|
? {
|
|
TURNSTILE_SITE_KEY: turnstileSiteKey,
|
|
TURNSTILE_SECRET_KEY: turnstileSecretKey,
|
|
}
|
|
: {}),
|
|
isProduction: NODE_ENV === "production",
|
|
isDevelopment: NODE_ENV === "development",
|
|
};
|
|
|
|
return cached;
|
|
}
|
|
|
|
/** Drop the memoised value. Only useful in tests. */
|
|
export function resetServerEnv(): void {
|
|
cached = undefined;
|
|
}
|
|
|
|
/**
|
|
* The origin canonical URLs and hreflang alternates are built from.
|
|
*
|
|
* SITE_URL wins when it is set to something other than the default, so a
|
|
* preview deployment behind a proxy still emits its own canonical. Otherwise
|
|
* the incoming request is trusted, honouring the standard forwarding headers.
|
|
*/
|
|
export function getSiteUrl(request: Request): string {
|
|
const env = getServerEnv();
|
|
if (process.env.SITE_URL?.trim() || env.isProduction) return env.SITE_URL;
|
|
|
|
const url = new URL(request.url);
|
|
const forwardedHost =
|
|
request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
|
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
|
|
const protocol = forwardedProto?.split(",")[0]?.trim() ?? url.protocol.replace(":", "");
|
|
const host = forwardedHost?.split(",")[0]?.trim() ?? url.host;
|
|
|
|
return trimTrailingSlash(`${protocol}://${host}`);
|
|
}
|