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"; const HAFAS_VER = process.env.HAFAS_VER || "1.36"; const HAFAS_LANG = process.env.HAFAS_LANG || "eng"; 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"; const HAFAS_BODY_MAX = 4 * 1024; // 4 KB const ALLOWED_METHODS = ["TripSearch", "LocMatch"] as const; type HafasMethod = (typeof ALLOWED_METHODS)[number]; interface HafasServiceRequest { meth: string; req?: Record; } interface HafasBody extends Record { svcReqL: HafasServiceRequest[]; } function injectHafasAuth( body: Record | null | undefined, ): Record { 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, }; } 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 }); } } 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) 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), 10); } 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 }); } }