04e793cbb8
Add `rankJourneys` utility to score connections based on arrival fit, directness, and duration. Update `JourneyList` to display the best option first with a "Top" badge and allow expanding to see more. Fix HAFAS parser to include train direction in journey details. Limit API result count to 5 and add TypeScript declaration for PNG assets.
217 lines
7.2 KiB
TypeScript
217 lines
7.2 KiB
TypeScript
import { randomUUID } from "crypto";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
|
|
import { hafasDateTime } from "@timetoleave/core";
|
|
import { readBodyWithLimit } from "@/lib/api-guards";
|
|
|
|
/** HAFAS protocol version identifier sent in every request envelope. */
|
|
const HAFAS_VER = process.env.HAFAS_VER || "1.36";
|
|
const HAFAS_LANG = process.env.HAFAS_LANG || "eng";
|
|
/** Application ID registered with ÖBB for HAFAS access. */
|
|
const HAFAS_AID = process.env.HAFAS_AID || "hf7mcf9bv3nv8g5f";
|
|
const HAFAS_CLIENT_ID = process.env.HAFAS_CLIENT_ID || "OEBB";
|
|
const HAFAS_CLIENT_VER = process.env.HAFAS_CLIENT_VER || "6020700";
|
|
const HAFAS_CLIENT_NAME = process.env.HAFAS_CLIENT_NAME || "oebbApp";
|
|
|
|
/** Hard limit on the POST body size to prevent abuse. */
|
|
const HAFAS_BODY_MAX = 4 * 1024; // 4 KB
|
|
|
|
/** Only these HAFAS service methods are allowed through the proxy. */
|
|
const ALLOWED_METHODS = ["TripSearch", "LocMatch"] as const;
|
|
type HafasMethod = (typeof ALLOWED_METHODS)[number];
|
|
|
|
/** Single service request entry inside a HAFAS envelope. */
|
|
interface HafasServiceRequest {
|
|
meth: string;
|
|
req?: Record<string, unknown>;
|
|
}
|
|
|
|
/** Full HAFAS request body with a list of service requests. */
|
|
interface HafasBody extends Record<string, unknown> {
|
|
svcReqL: HafasServiceRequest[];
|
|
}
|
|
|
|
/**
|
|
* Injects required HAFAS protocol fields (version, language, auth, client)
|
|
* into the outgoing request. These fields identify the caller to ÖBB's system.
|
|
*
|
|
* @param body - Client-provided partial body (typically containing `svcReqL`).
|
|
* @returns Fully assembled HAFAS request envelope.
|
|
*/
|
|
function injectHafasAuth(
|
|
body: Record<string, unknown> | null | undefined,
|
|
): Record<string, unknown> {
|
|
if (!body || typeof body !== "object") {
|
|
return {};
|
|
}
|
|
return {
|
|
ver: HAFAS_VER,
|
|
lang: HAFAS_LANG,
|
|
auth: { type: "AID", aid: HAFAS_AID },
|
|
client: { id: HAFAS_CLIENT_ID, v: HAFAS_CLIENT_VER, type: "IPH", name: HAFAS_CLIENT_NAME },
|
|
...body,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* GET handler — convenience endpoint for simple trip searches.
|
|
*
|
|
* Accepts `from`, `to` (numeric ÖBB station IDs) and `date` as query params.
|
|
* Constructs a TripSearch request, injects auth, and forwards it to HAFAS.
|
|
*
|
|
* This is a simplified interface; the POST handler below supports arbitrary
|
|
* HAFAS envelopes (TripSearch, LocMatch).
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const { searchParams } = new URL(request.url);
|
|
|
|
const from = searchParams.get("from");
|
|
const to = searchParams.get("to");
|
|
const date = searchParams.get("date");
|
|
|
|
if (!from || !to || !date) {
|
|
return NextResponse.json({ error: "Missing required parameters: from, to, date" }, { status: 400 });
|
|
}
|
|
|
|
// Validate extId shape — ÖBB station IDs are purely numeric
|
|
if (!/^\d+$/.test(from) || !/^\d+$/.test(to)) {
|
|
return NextResponse.json({ error: "Invalid station ID format (must be numeric)" }, { status: 400 });
|
|
}
|
|
|
|
const dateObj = new Date(date);
|
|
if (isNaN(dateObj.getTime())) {
|
|
return NextResponse.json({ error: "Invalid date format" }, { status: 400 });
|
|
}
|
|
|
|
const { date: hafasDate, time: hafasTime } = hafasDateTime(dateObj);
|
|
|
|
const body = injectHafasAuth({
|
|
svcReqL: [
|
|
{
|
|
meth: "TripSearch",
|
|
req: {
|
|
depLocL: [{ type: "S", extId: from }],
|
|
arrLocL: [{ type: "S", extId: to }],
|
|
outDate: hafasDate,
|
|
outTime: hafasTime,
|
|
numF: 5,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
|
|
|
|
const response = await fetch(HAFAS_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
return NextResponse.json({ error: "HAFAS request failed" }, { status: response.status });
|
|
}
|
|
|
|
const data = await response.json();
|
|
return NextResponse.json(data);
|
|
} catch (error: unknown) {
|
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
return NextResponse.json({ error: "HAFAS request timeout" }, { status: 408 });
|
|
}
|
|
|
|
const corrId = randomUUID().slice(0, 8);
|
|
console.error(`[${corrId}] HAFAS API error:`, error);
|
|
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST handler — generic HAFAS proxy for advanced callers.
|
|
*
|
|
* Accepts a JSON body containing a HAFAS service request (`svcReqL`).
|
|
* Validates the method is allowed, caps TripSearch results at 10,
|
|
* injects auth credentials, and forwards to the ÖBB HAFAS endpoint.
|
|
*
|
|
* Timeout is enforced via AbortController. Errors include a correlation ID
|
|
* for server-side log lookup.
|
|
*/
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
// Body size guard before parsing
|
|
const rawBody = await readBodyWithLimit(request, HAFAS_BODY_MAX);
|
|
|
|
if (!rawBody || rawBody.trim().length === 0) {
|
|
return NextResponse.json({ error: "Missing request body" }, { status: 400 });
|
|
}
|
|
|
|
let body: unknown;
|
|
try {
|
|
body = JSON.parse(rawBody);
|
|
} catch {
|
|
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
}
|
|
|
|
// Inject required HAFAS protocol fields
|
|
const hafasBody = injectHafasAuth(body as Record<string, unknown>) as HafasBody;
|
|
|
|
// Validate enriched body
|
|
if (!hafasBody || !Array.isArray(hafasBody.svcReqL) || hafasBody.svcReqL.length === 0) {
|
|
return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 });
|
|
}
|
|
|
|
const svcReq = hafasBody.svcReqL[0] as HafasServiceRequest;
|
|
|
|
if (
|
|
!svcReq ||
|
|
typeof svcReq.meth !== "string" ||
|
|
!(ALLOWED_METHODS as readonly string[]).includes(svcReq.meth)
|
|
) {
|
|
return NextResponse.json(
|
|
{ error: `Invalid HAFAS method. Allowed: ${ALLOWED_METHODS.join(", ")}` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Cap TripSearch results at 10
|
|
if (svcReq.meth === "TripSearch" && svcReq.req && typeof svcReq.req.numF !== "undefined") {
|
|
svcReq.req.numF = Math.min(Number(svcReq.req.numF), 5);
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
|
|
|
|
const response = await fetch(HAFAS_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(hafasBody),
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
return NextResponse.json({ error: "HAFAS request failed" }, { status: response.status });
|
|
}
|
|
|
|
const data = await response.json();
|
|
return NextResponse.json(data);
|
|
} catch (error: unknown) {
|
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
return NextResponse.json({ error: "HAFAS request timeout" }, { status: 408 });
|
|
}
|
|
|
|
const corrId = randomUUID().slice(0, 8);
|
|
console.error(`[${corrId}] HAFAS API error:`, error);
|
|
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
|
|
}
|
|
}
|