09b5e7725d
Introduce `trainWalkDurationSeconds` in `useDepartureTime` hooks for both mobile and web apps to filter train journeys based on total arrival time including walking. Add default origin station constants in core package and use them in mobile store instead of returning null when no origin is saved. Normalize HAFAS coordinates in destination station hooks to handle large integer values. Update `cleanLocation` to preserve full addresses with commas and remove the 10KB request body limit on ICS parsing to support larger calendar files. Make rate limiting configurable via environment variables to handle higher API fan-out from calendar event pages.
129 lines
4.2 KiB
TypeScript
129 lines
4.2 KiB
TypeScript
// ============================================================================
|
|
// Next.js Middleware — Strict CORS + Per-IP Rate Limiting
|
|
// ============================================================================
|
|
//
|
|
// Replaces the previous wildcard-CORS middleware. Only origins listed in the
|
|
// `CORS_ALLOWED_ORIGINS` environment variable are permitted. An in-memory
|
|
// sliding-window rate limiter enforces a global per-IP cap on /api/ routes.
|
|
// ============================================================================
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { RateLimiter } from "@/lib/rate-limiter";
|
|
|
|
// ---------- Configuration ----------
|
|
|
|
// Comma-separated list of allowed origins from env; falls back to localhost
|
|
// for local development when the variable is not set.
|
|
const rawOrigins =
|
|
typeof process.env.CORS_ALLOWED_ORIGINS === "string"
|
|
? process.env.CORS_ALLOWED_ORIGINS
|
|
: "";
|
|
|
|
const allowedOrigins: Set<string> = new Set(
|
|
rawOrigins
|
|
? rawOrigins.split(",").map((s) => s.trim()).filter(Boolean)
|
|
: ["http://localhost:3000"], // dev fallback
|
|
);
|
|
|
|
// Rate limiter: tune through env for deployment. Calendar pages legitimately
|
|
// fan out across geocode, routing, transit, and nearby-stop APIs per event.
|
|
const rateLimitMaxRequests = Number.parseInt(process.env.API_RATE_LIMIT_MAX_REQUESTS ?? "120", 10);
|
|
const rateLimitWindowMs = Number.parseInt(process.env.API_RATE_LIMIT_WINDOW_MS ?? "60000", 10);
|
|
|
|
const limiter = new RateLimiter({
|
|
maxRequests: Number.isFinite(rateLimitMaxRequests) ? rateLimitMaxRequests : 120,
|
|
windowMs: Number.isFinite(rateLimitWindowMs) ? rateLimitWindowMs : 60_000,
|
|
});
|
|
|
|
// ---------- Helpers ----------
|
|
|
|
/** Resolve the real client IP from common proxy headers. */
|
|
function getClientIp(request: NextRequest): string {
|
|
// X-Forwarded-For may contain a chain: "client, proxy1, proxy2"
|
|
const xff = request.headers.get("x-forwarded-for");
|
|
if (xff) {
|
|
return xff.split(",")[0].trim();
|
|
}
|
|
const xri = request.headers.get("x-real-ip");
|
|
if (xri) {
|
|
return xri.trim();
|
|
}
|
|
// Fallback — works when there is no reverse proxy
|
|
return (request as NextRequest & { ip?: string }).ip ?? "unknown";
|
|
}
|
|
|
|
function buildCorsHeaders(origin: string | null): HeadersInit {
|
|
if (!origin || !allowedOrigins.has(origin)) {
|
|
return { Vary: "Origin" };
|
|
}
|
|
return {
|
|
"Access-Control-Allow-Origin": origin,
|
|
Vary: "Origin",
|
|
};
|
|
}
|
|
|
|
// ---------- Middleware ----------
|
|
|
|
export default function proxy(request: NextRequest) {
|
|
// Only apply CORS + rate-limit headers to API routes
|
|
if (!request.nextUrl.pathname.startsWith("/api/")) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
const origin = request.headers.get("origin") ?? null;
|
|
|
|
// Preflight
|
|
if (request.method === "OPTIONS") {
|
|
const response = new NextResponse(null, { status: 200 });
|
|
Object.entries(buildCorsHeaders(origin)).forEach(([k, v]) =>
|
|
response.headers.set(k, v),
|
|
);
|
|
response.headers.set(
|
|
"Access-Control-Allow-Methods",
|
|
"GET, POST, OPTIONS",
|
|
);
|
|
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
response.headers.set("Access-Control-Max-Age", "86400");
|
|
return response;
|
|
}
|
|
|
|
// Build the downstream response
|
|
const response = NextResponse.next();
|
|
|
|
// Attach CORS headers (only for allowed origins)
|
|
Object.entries(buildCorsHeaders(origin)).forEach(([k, v]) =>
|
|
response.headers.set(k, v),
|
|
);
|
|
|
|
// Rate limiting (skip for OPTIONS — already handled above)
|
|
if (request.method !== "OPTIONS") {
|
|
const clientIp = getClientIp(request);
|
|
const result = limiter.check(`ip:${clientIp}`);
|
|
|
|
if (!result.allowed) {
|
|
const denied = NextResponse.json(
|
|
{ error: "Rate limit exceeded. Try again later." },
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
"X-RateLimit-Limit": String(result.limit),
|
|
"X-RateLimit-Remaining": "0",
|
|
"Retry-After": String(Math.ceil(result.resetAt / 1000)),
|
|
Vary: "Origin",
|
|
},
|
|
},
|
|
);
|
|
return denied;
|
|
}
|
|
|
|
response.headers.set("X-RateLimit-Limit", String(result.limit));
|
|
response.headers.set("X-RateLimit-Remaining", String(result.remaining));
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: "/api/:path*",
|
|
};
|