240 lines
6.1 KiB
TypeScript

const DEFAULT_ALLOWED_METHODS = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
];
const DEFAULT_ALLOWED_HEADERS = [
"Content-Type",
"Authorization",
"X-Requested-With",
];
const DEFAULT_EXPOSED_HEADERS = ["Content-Length", "Content-Type"];
const IS_DEVELOPMENT = Deno.env.get("DEVELOPMENT") === "true";
export interface CorsOptions {
allowedOrigins: Set<string>;
allowCredentials?: boolean;
allowedMethods?: string[];
allowedHeaders?: string[];
exposeHeaders?: string[];
requestHeaders?: string | null;
maxAgeSeconds?: number;
}
function normalizeOrigin(raw: string | null | undefined): string | null {
if (!raw) return null;
try {
const trimmed = raw.trim();
const hasScheme = /^https?:\/\//i.test(trimmed);
const needsHttp = /^(localhost|127\.0\.0\.1)/i.test(trimmed);
const candidate = hasScheme
? trimmed
: `${needsHttp ? "http" : "https"}://${trimmed}`;
const url = new URL(candidate);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
}
function parseOriginList(raw: string | null | undefined): Set<string> {
const allowed = new Set<string>();
if (!raw) return allowed;
for (const entry of raw.split(",")) {
const normalized = normalizeOrigin(entry.trim());
if (normalized) {
allowed.add(normalized);
}
}
return allowed;
}
function deriveDefaultEdgeOrigins(): Set<string> {
const allowed = new Set<string>();
const redirectHome = normalizeOrigin(Deno.env.get("REDIRECT_HOME"));
if (redirectHome) {
allowed.add(redirectHome);
}
const hostname = Deno.env.get("HOSTNAME_DENO");
const port = Deno.env.get("PORT");
if (hostname && port) {
const devOrigin = normalizeOrigin(`http://${hostname}:${port}`);
if (devOrigin) {
allowed.add(devOrigin);
}
}
return allowed;
}
const EDGE_ALLOWED_ORIGINS = (() => {
const fromEnv = parseOriginList(Deno.env.get("EDGE_ALLOWED_ORIGINS"));
if (fromEnv.size > 0) {
return fromEnv;
}
return deriveDefaultEdgeOrigins();
})();
const ISLAND_ALLOWED_ORIGINS = new Set([
"https://cyberhybridhub.com",
"http://cyberhybridhub.com",
"http://localhost:8080",
"http://96.3.218.128",
"https://96.3.218.126",
]);
function mergeVary(current: string | null, incoming: string): string {
const parts = new Set<string>();
for (const entry of (current ?? "").split(",")) {
const trimmed = entry.trim();
if (trimmed) {
parts.add(trimmed);
}
}
for (const entry of incoming.split(",")) {
const trimmed = entry.trim();
if (trimmed) {
parts.add(trimmed);
}
}
return Array.from(parts).join(", ");
}
function buildAllowedHeaders(
baseHeaders: string[],
requestHeaders?: string | null,
): string[] {
const allowed = new Set(baseHeaders.map((header) => header.toLowerCase()));
if (requestHeaders) {
for (const header of requestHeaders.split(",")) {
const trimmed = header.trim();
if (trimmed) {
allowed.add(trimmed.toLowerCase());
}
}
}
return Array.from(allowed).map((header) =>
header
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("-")
);
}
export function isIslandFetchRequestAllowed(
url: URL,
originHeader?: string | null,
): boolean {
if (IS_DEVELOPMENT) return true;
// Check if the request origin is allowed
if (originHeader) {
const normalizedOrigin = normalizeOrigin(originHeader);
if (normalizedOrigin && ISLAND_ALLOWED_ORIGINS.has(normalizedOrigin)) {
return true;
}
}
// Fallback to checking the URL origin
return ISLAND_ALLOWED_ORIGINS.has(url.origin);
}
export function getCorsHeaders(
origin: string | null,
options: CorsOptions,
): Headers {
const headers = new Headers();
const normalizedOrigin = normalizeOrigin(origin);
if (!normalizedOrigin || !options.allowedOrigins.has(normalizedOrigin)) {
return headers;
}
headers.set("Access-Control-Allow-Origin", normalizedOrigin);
headers.set(
"Access-Control-Allow-Methods",
(options.allowedMethods ?? DEFAULT_ALLOWED_METHODS).join(", "),
);
headers.set(
"Access-Control-Allow-Headers",
buildAllowedHeaders(
options.allowedHeaders ?? DEFAULT_ALLOWED_HEADERS,
options.requestHeaders,
).join(", "),
);
headers.set(
"Access-Control-Expose-Headers",
(options.exposeHeaders ?? DEFAULT_EXPOSED_HEADERS).join(", "),
);
if (options.allowCredentials ?? true) {
headers.set("Access-Control-Allow-Credentials", "true");
}
if (options.maxAgeSeconds) {
headers.set("Access-Control-Max-Age", `${options.maxAgeSeconds}`);
}
headers.set(
"Vary",
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
);
return headers;
}
export function getEdgeCorsHeaders(
origin: string | null,
requestHeaders?: string | null,
): Headers {
return getCorsHeaders(origin, {
allowedOrigins: EDGE_ALLOWED_ORIGINS,
requestHeaders,
});
}
export function getIslandCorsHeaders(
origin: string | null,
requestHeaders?: string | null,
): Headers {
return getCorsHeaders(origin, {
allowedOrigins: ISLAND_ALLOWED_ORIGINS,
requestHeaders,
});
}
export function withCors(response: Response, corsHeaders: Headers): Response {
if (!corsHeaders || corsHeaders.values().toArray().length === 0) {
return response;
}
const headers = new Headers(response.headers);
for (const [key, value] of corsHeaders.entries()) {
if (key.toLowerCase() === "vary") {
headers.set("Vary", mergeVary(headers.get("Vary"), value));
} else {
headers.set(key, value);
}
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
export function createCorsPreflightResponse(
_request: Request,
corsHeaders: Headers,
): Response {
if (!corsHeaders || !corsHeaders.get("Access-Control-Allow-Origin")) {
return new Response("Forbidden", { status: 403 });
}
return new Response(null, { status: 204, headers: corsHeaders });
}
export function getEdgeAllowedOrigins(): string[] {
return Array.from(EDGE_ALLOWED_ORIGINS);
}