* Refactor aircraft photo and hero banner components to reset loading state on photo change - Updated Lightbox component to reset image loading state when navigating between photos. - Modified HeroBanner component to reset loading state when the photo changes. Clean up control panel search logic - Removed unnecessary hasResults variable in SearchContent component. Implement flight API client with fallback mechanism - Added flight-api-client to handle fetching flight data from multiple sources (airplanes.live, adsb.lol, OpenSky). - Introduced flight-api-parsing module to convert raw API responses into standardized FlightState objects. - Created flight-api-types for shared types between API responses. Refactor useFlights hook to utilize new flight API client - Updated useFlights hook to fetch flights using the new flight API client. - Removed credit management logic as it is no longer applicable with the new API structure. Fix useFlightMonitors to fetch flight data by hex address - Changed useFlightMonitors to use fetchFlightByHex instead of fetchFlightByIcao24. Update geo utility function for better readability - Refactored splitAtAntimeridian function to improve variable naming and clarity. Enhance OpenSky types with additional fields - Added typeCode and registration fields to FlightState type for better integration with readsb data. * fix: correct 6 files that diverged during rebase (iata code, globe mode ref, terrain attribution, cache eviction, opensky parsing) * fix: improve keyboard shortcuts help focus trapping feat: add showAirspace option to MapAttribution component fix: clear hideTimer on ScrollArea cleanup refactor: change pendingFpvRef to MutableRefObject in useFlightMonitors fix: handle sessionStorage availability in useFlightTrack refactor: increase POLL_INTERVAL_MS in useFlights for better performance fix: optimize keyboard shortcuts dialog check refactor: optimize useMergedTrails by caching selected flight position feat: extend Settings type with airspace options refactor: improve airline logo normalization functions refactor: enhance flight API client with serialized rate limiting refactor: optimize registration country lookup with pre-built maps refactor: enhance logo cache management with size limits feat: update map attribution to include airspace option fix: validate rawState in parseStateRow function refactor: improve utility functions with clamp implementation * feat: add ATC lookup functionality and GPU memory monitoring - Implemented ATC lookup functions in `atc-lookup.ts` for converting IATA to ICAO codes, finding nearby ATC feeds, and looking up ATC feeds by code. - Introduced `atc-types.ts` to define types and priorities for ATC feeds. - Added GPU memory monitoring in `gpu-memory-monitor.ts` to track WebGL resource allocations and provide memory reports. - Enhanced trail stitching logic in `trail-stitching.ts` by adding a function to clear the splined track cache and optimizing altitude checks. * feat: enhance flight data handling and improve API resilience - Implemented a maximum empty response streak guard in useFlights to prevent data loss during transient API failures. - Added immediate fetch on network reconnect in useFlights to ensure timely data retrieval. - Updated useMergedTrails to include timestamps for trail points. - Removed smoothAnimations setting from useSettings as it is no longer needed. - Enhanced useTrailHistory to preserve last-known trails during empty flight responses and added dynamic jump detection for tab resume scenarios. - Improved flight API client with a circuit breaker mechanism to handle provider failures and prevent excessive retries. - Updated flight API parsing to reject non-JSON responses from OpenSky and other providers. - Enhanced trail smoothing and stitching logic to ensure better continuity at junctions between historical and live data. * feat: migrate aircraft models to Cloudinary CDN and update mapping logic * fix: adjust UI component styles and improve trail smoothing parameters * fix: adjust base aircraft size for improved rendering * feat: update changelog with recent enhancements and modify data source attribution * fix: update model optimization details and remove Draco compression dependency * feat: update changelog with recent code review fixes and fallback provider adjustments
161 lines
4.8 KiB
TypeScript
161 lines
4.8 KiB
TypeScript
import { type NextRequest } from "next/server";
|
|
import { VALID_MOUNT_POINTS } from "@/lib/atc-feeds";
|
|
|
|
/**
|
|
* GET /api/atc/stream?mount={mountPoint}
|
|
*
|
|
* Fallback audio stream proxy for LiveATC Icecast streams.
|
|
* Only used when direct browser <audio> playback is blocked.
|
|
*
|
|
* Security:
|
|
* - Mount point validated against static allowlist (SSRF prevention)
|
|
* - Connection timeout: 30 seconds
|
|
* - Max stream duration: 4 hours
|
|
* - Simple per-request rate limiting via headers
|
|
*/
|
|
|
|
/** Maximum stream duration in milliseconds (4 hours). */
|
|
const MAX_STREAM_DURATION_MS = 4 * 60 * 60 * 1000;
|
|
/** Connection timeout for upstream fetch (30 seconds). */
|
|
const CONNECT_TIMEOUT_MS = 30_000;
|
|
|
|
/**
|
|
* Sanitize and validate mount point parameter.
|
|
* Only alphanumeric characters, underscores, and hyphens are allowed.
|
|
*/
|
|
function isValidMountFormat(mount: string): boolean {
|
|
return /^[a-z0-9_-]{2,64}$/i.test(mount);
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const mount = request.nextUrl.searchParams.get("mount")?.trim();
|
|
|
|
if (!mount) {
|
|
return new Response(
|
|
JSON.stringify({ error: "Missing required 'mount' parameter." }),
|
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
// Validate mount point format
|
|
if (!isValidMountFormat(mount)) {
|
|
return new Response(
|
|
JSON.stringify({ error: "Invalid mount point format." }),
|
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
// SSRF prevention: only allow mount points from our static database
|
|
if (!VALID_MOUNT_POINTS.has(mount)) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: "Unknown mount point. Only verified feeds are allowed.",
|
|
}),
|
|
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
// Construct the upstream URL from the validated mount point
|
|
// Using the direct Icecast server URL (d.liveatc.net)
|
|
const upstreamUrl = `https://d.liveatc.net/${mount}`;
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const connectTimer = setTimeout(
|
|
() => controller.abort(),
|
|
CONNECT_TIMEOUT_MS,
|
|
);
|
|
|
|
const upstream = await fetch(upstreamUrl, {
|
|
signal: controller.signal,
|
|
headers: {
|
|
"User-Agent": "Mozilla/5.0 (compatible; Aeris/1.0)",
|
|
Referer: "https://www.liveatc.net/",
|
|
Accept: "audio/mpeg, audio/*, */*",
|
|
},
|
|
});
|
|
|
|
clearTimeout(connectTimer);
|
|
|
|
if (!upstream.ok) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: "Upstream stream unavailable.",
|
|
status: upstream.status,
|
|
}),
|
|
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
if (!upstream.body) {
|
|
return new Response(
|
|
JSON.stringify({ error: "No stream body from upstream." }),
|
|
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
// Set up max duration cutoff
|
|
const durationController = new AbortController();
|
|
const durationTimer = setTimeout(
|
|
() => durationController.abort(),
|
|
MAX_STREAM_DURATION_MS,
|
|
);
|
|
|
|
// Pipe the upstream stream through, respecting both abort signals
|
|
const reader = upstream.body.getReader();
|
|
const stream = new ReadableStream({
|
|
async pull(ctrl) {
|
|
try {
|
|
if (durationController.signal.aborted) {
|
|
reader.cancel().catch(() => {});
|
|
clearTimeout(durationTimer);
|
|
ctrl.close();
|
|
return;
|
|
}
|
|
const { value, done } = await reader.read();
|
|
if (done) {
|
|
ctrl.close();
|
|
} else {
|
|
ctrl.enqueue(value);
|
|
}
|
|
} catch {
|
|
reader.cancel().catch(() => {});
|
|
ctrl.close();
|
|
}
|
|
},
|
|
cancel() {
|
|
clearTimeout(durationTimer);
|
|
reader.cancel().catch(() => {});
|
|
},
|
|
});
|
|
|
|
// Detect client disconnect via request abort signal
|
|
request.signal.addEventListener("abort", () => {
|
|
clearTimeout(durationTimer);
|
|
reader.cancel().catch(() => {});
|
|
});
|
|
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": upstream.headers.get("Content-Type") ?? "audio/mpeg",
|
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
"X-Accel-Buffering": "no", // Disable Nginx buffering if behind reverse proxy
|
|
"Access-Control-Allow-Origin": "*",
|
|
},
|
|
});
|
|
} catch (err) {
|
|
const isAbort = err instanceof Error && err.name === "AbortError";
|
|
if (isAbort) {
|
|
return new Response(
|
|
JSON.stringify({ error: "Connection to upstream timed out." }),
|
|
{ status: 504, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
return new Response(
|
|
JSON.stringify({ error: "Failed to connect to upstream stream." }),
|
|
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
}
|