2026-05-13 23:25:49 -05:00
|
|
|
import { wrapServerWithClientIp, type RequestServerLike } from "./client-ip";
|
2026-05-30 23:20:15 -05:00
|
|
|
import { consumeUnauthorizedRateLimit } from "../rateLimit";
|
2026-05-13 23:25:49 -05:00
|
|
|
|
2026-05-06 18:24:33 -05:00
|
|
|
export type RouteHandler = (req: Request, url: URL, server: unknown) => Promise<Response | null>;
|
2026-03-05 00:04:26 -06:00
|
|
|
|
2026-06-04 16:35:06 -05:00
|
|
|
const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
|
const LONG_ID_SEGMENT = /^[0-9a-f]{16,}$/i;
|
|
|
|
|
|
2026-05-30 23:20:15 -05:00
|
|
|
function tooManyRequestsResponse(retryAfter = 60): Response {
|
|
|
|
|
return new Response("Too many requests", {
|
|
|
|
|
status: 429,
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "text/plain; charset=utf-8",
|
|
|
|
|
"Retry-After": String(retryAfter),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 16:35:06 -05:00
|
|
|
function normalizeRouteSegment(segment: string): string {
|
|
|
|
|
if (/^\d+$/.test(segment)) return ":id";
|
|
|
|
|
if (UUID_SEGMENT.test(segment) || LONG_ID_SEGMENT.test(segment)) return ":id";
|
|
|
|
|
if (segment.includes(".") && !segment.startsWith(".")) return ":file";
|
|
|
|
|
if (segment.length >= 24 && /^[a-z0-9_-]+$/i.test(segment)) return ":id";
|
|
|
|
|
return segment;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function normalizeHttpRoute(req: Request, url: URL): string {
|
|
|
|
|
const path = url.pathname
|
|
|
|
|
.split("/")
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.map(normalizeRouteSegment)
|
|
|
|
|
.join("/");
|
|
|
|
|
return `${req.method.toUpperCase()} /${path}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 18:24:33 -05:00
|
|
|
export function createHttpFetchHandler(deps: {
|
2026-06-04 16:35:06 -05:00
|
|
|
metrics: { withHttpMetrics: (fn: () => Promise<Response>, route?: string) => Promise<Response> };
|
2026-03-05 00:04:26 -06:00
|
|
|
CORS_HEADERS: Record<string, string>;
|
2026-05-06 18:24:33 -05:00
|
|
|
routes: RouteHandler[];
|
|
|
|
|
}) {
|
2026-03-05 00:04:26 -06:00
|
|
|
return async function fetchHandler(req: Request, server: unknown): Promise<Response> {
|
2026-06-04 16:35:06 -05:00
|
|
|
const url = new URL(req.url);
|
|
|
|
|
const routeLabel = normalizeHttpRoute(req, url);
|
2026-03-05 00:04:26 -06:00
|
|
|
return deps.metrics.withHttpMetrics(async () => {
|
|
|
|
|
if (req.method === "OPTIONS") {
|
|
|
|
|
return new Response("", { headers: deps.CORS_HEADERS });
|
|
|
|
|
}
|
2026-05-13 23:25:49 -05:00
|
|
|
const wrapped = wrapServerWithClientIp(server as RequestServerLike);
|
2026-05-30 23:20:15 -05:00
|
|
|
const ip = wrapped.requestIP(req)?.address || "unknown";
|
|
|
|
|
|
2026-05-06 18:24:33 -05:00
|
|
|
for (const route of deps.routes) {
|
2026-05-13 23:25:49 -05:00
|
|
|
const response = await route(req, url, wrapped);
|
2026-05-30 23:20:15 -05:00
|
|
|
if (response) {
|
|
|
|
|
if (response.status === 401) {
|
|
|
|
|
const limited = consumeUnauthorizedRateLimit(ip);
|
|
|
|
|
if (limited.limited) return tooManyRequestsResponse(limited.retryAfter);
|
|
|
|
|
}
|
|
|
|
|
return response;
|
|
|
|
|
}
|
2026-05-06 18:24:33 -05:00
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
return new Response("Not found", { status: 404 });
|
2026-06-04 16:35:06 -05:00
|
|
|
}, routeLabel);
|
2026-03-05 00:04:26 -06:00
|
|
|
};
|
|
|
|
|
}
|