diff --git a/.env.example b/.env.example index d9d2fc63..8ddf7868 100644 --- a/.env.example +++ b/.env.example @@ -25,4 +25,7 @@ VATSIM_CLIENT_SECRET=YOUR_VATSIM_CLIENT_SECRET_HERE # Get it from https://vatsim VATSIM_REDIRECT_URI=http://localhost:9901/api/auth/vatsim/callback VATSIM_AUTH_BASE= # https://auth.vatsim.net or https://auth-dev.vatsim.net depending on environment +PFATC_SERVER_ID=2ykygVZiX5 +PFATC_PROXIES=PROXY_URL,PROXY_URL,PROXY_URL,PROXY_URL,PROXY_URL + ADMIN_IDS=1234567891011121314,123456789101112131415 # Comma separated list of Discord User IDs who should have admin access \ No newline at end of file diff --git a/server/routes/pilot.ts b/server/routes/pilot.ts index 6de3e40e..5049099f 100644 --- a/server/routes/pilot.ts +++ b/server/routes/pilot.ts @@ -3,6 +3,9 @@ import { getUserByUsername } from '../db/users.js'; import { mainDb } from '../db/connection.js'; import { isAdmin } from '../middleware/admin.js'; import { getControllerRatingStats } from '../db/ratings.js'; +import { trafficScraper } from '../utils/trafficScraper.js'; +import requireAuth from '../middleware/auth.js'; +import { getAirlineData } from '../utils/getData.js'; const router = express.Router(); @@ -101,4 +104,70 @@ router.get('/:username', async (req, res) => { } }); +// GET: /api/pilot/callsign - Get current callsign for logged in user +router.get('/callsign/data', requireAuth, async (req, res) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const user = await mainDb + .selectFrom('users') + .select(['roblox_username']) + .where('id', '=', req.user.userId) + .executeTakeFirst(); + + if (!user || !user.roblox_username) { + return res.status(404).json({ error: 'Roblox account not connected' }); + } + + const trafficData = trafficScraper.getCallsignForUser(user.roblox_username); + + if (!trafficData) { + return res.status(404).json({ error: 'No active flight found for this user' }); + } + + let airlineName = null; + let convertedCallsign = trafficData.callsign; + + if (trafficData.callsign) { + const airlineData = getAirlineData(); + interface Airline { + icao: string; + callsign: string; + } + + const sortedAirlines = [...airlineData].sort((a, b) => b.callsign.length - a.callsign.length); + + const upperCallsign = trafficData.callsign.toUpperCase(); + const match = sortedAirlines.find((a: Airline) => upperCallsign.startsWith(a.callsign.toUpperCase())); + + if (match) { + airlineName = match.callsign; + const remaining = upperCallsign.substring(match.callsign.length).trim(); + convertedCallsign = `${match.icao}${remaining}`; + } else { + const icaoMatch = trafficData.callsign.match(/^([A-Z]{3})/); + if (icaoMatch) { + const icao = icaoMatch[1]; + const airline = airlineData.find((a: Airline) => a.icao === icao); + if (airline) { + airlineName = airline.callsign; + } + } + } + } + + res.json({ + ...trafficData, + callsign: convertedCallsign, + originalCallsign: trafficData.callsign, + airlineName, + }); + } catch (error) { + console.error('Error fetching pilot callsign:', error); + res.status(500).json({ error: 'Failed to fetch pilot callsign' }); + } +}); + export default router; diff --git a/server/utils/getData.ts b/server/utils/getData.ts index 356f9cc3..198bb346 100644 --- a/server/utils/getData.ts +++ b/server/utils/getData.ts @@ -8,6 +8,7 @@ const __dirname = path.dirname(__filename); const airportsPath = path.join(__dirname, '..', 'data', 'airportData.json'); const aircraftPath = path.join(__dirname, '..', 'data', 'aircraftData.json'); const waypointsPath = path.join(__dirname, '..', 'data', 'waypointData.json'); +const airlinesPath = path.join(__dirname, '..', 'data', 'airlineData.json'); export function getAirportData() { if (!fs.existsSync(airportsPath)) { @@ -28,4 +29,11 @@ export function getWaypointData() { throw new Error('Waypoint data not found'); } return JSON.parse(fs.readFileSync(waypointsPath, 'utf8')); +} + +export function getAirlineData() { + if (!fs.existsSync(airlinesPath)) { + throw new Error('Airline data not found'); + } + return JSON.parse(fs.readFileSync(airlinesPath, 'utf8')); } \ No newline at end of file diff --git a/server/utils/trafficScraper.ts b/server/utils/trafficScraper.ts new file mode 100644 index 00000000..e623ac88 --- /dev/null +++ b/server/utils/trafficScraper.ts @@ -0,0 +1,169 @@ +import WebSocket from 'ws'; +import protobuf from 'protobufjs'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import dotenv from 'dotenv'; +import type { Agent } from 'http'; + +dotenv.config(); + +const root = new protobuf.Root(); +const data = root.define('data'); + +data.add( + new protobuf.Type('Timestamp').add( + new protobuf.Field('timestamp', 1, 'uint64') + ) +); + +data.add( + new protobuf.Type('Waypoint') + .add(new protobuf.Field('x', 1, 'double')) + .add(new protobuf.Field('y', 2, 'double')) + .add(new protobuf.Field('airport_code', 3, 'string')) + .add(new protobuf.Field('runway', 4, 'string')) + .add(new protobuf.Field('distance_or_bearing', 5, 'double')) + .add(new protobuf.Field('time', 6, 'Timestamp')) +); + +data.add( + new protobuf.Type('Plane') + .add(new protobuf.Field('server_id', 1, 'string')) + .add(new protobuf.Field('callsign', 2, 'string')) + .add(new protobuf.Field('roblox_username', 3, 'string')) + .add(new protobuf.Field('x', 4, 'double')) + .add(new protobuf.Field('y', 5, 'double')) + .add(new protobuf.Field('heading', 6, 'double')) + .add(new protobuf.Field('altitude', 7, 'double')) + .add(new protobuf.Field('speed', 8, 'double')) + .add(new protobuf.Field('model', 9, 'string')) + .add(new protobuf.Field('livery', 10, 'string')) +); + +data.add( + new protobuf.Type('planes') + .add(new protobuf.Field('planes', 1, 'Plane', 'repeated')) + .add(new protobuf.Field('waypoints', 2, 'Waypoint', 'repeated')) +); + +const planesType = root.lookupType('data.planes'); + +interface PlaneData { + callsign: string; + robloxUsername: string; + model: string; +} + +class TrafficScraper { + private static instance: TrafficScraper; + private ws: WebSocket | null = null; + private traffic: Map = new Map(); + private reconnectInterval: NodeJS.Timeout | null = null; + private currentProxyIndex: number = 0; + + private constructor() { + this.connect(); + } + + public static getInstance(): TrafficScraper { + if (!TrafficScraper.instance) { + TrafficScraper.instance = new TrafficScraper(); + } + return TrafficScraper.instance; + } + + private getProxyList(): string[] { + const envProxies = process.env.PFATC_PROXIES; + if (envProxies) { + return envProxies.split(',').map((p) => p.trim()).filter(Boolean); + } + const singleProxy = process.env.PFATC_PROXY_URL; + return singleProxy ? [singleProxy] : []; + } + + private connect() { + const proxies = this.getProxyList(); + const proxyUrl = proxies[this.currentProxyIndex % proxies.length]; + const SERVER_ID = process.env.PFATC_SERVER_ID || '2ykygVZiX5'; + const WS_URL = `wss://v3api.project-flight.com/v3/traffic/server/ws/${SERVER_ID}`; + + const wsOptions: WebSocket.ClientOptions = { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Origin: 'https://scope.project-flight.com', + }, + }; + + if (proxyUrl) { + wsOptions.agent = new HttpsProxyAgent(proxyUrl) as unknown as Agent; + } + + this.ws = new WebSocket(WS_URL, wsOptions); + + this.ws.on('open', () => { + if (this.reconnectInterval) { + clearInterval(this.reconnectInterval); + this.reconnectInterval = null; + } + }); + + this.ws.on('message', async (messageData: WebSocket.Data) => { + try { + let buffer: Uint8Array; + if (messageData instanceof Buffer) { + buffer = new Uint8Array(messageData); + } else if (Array.isArray(messageData)) { + buffer = new Uint8Array(Buffer.concat(messageData)); + } else if (messageData instanceof ArrayBuffer) { + buffer = new Uint8Array(messageData); + } else { + buffer = new TextEncoder().encode(messageData.toString()); + } + + const decoded = planesType.decode(buffer); + const object = planesType.toObject(decoded, { + defaults: true, + longs: String, + enums: String, + bytes: String, + }) as { planes?: Array<{ roblox_username?: string; callsign?: string; model?: string }> }; + + if (object.planes && Array.isArray(object.planes)) { + object.planes.forEach((plane) => { + if (plane.roblox_username) { + this.traffic.set(plane.roblox_username.toLowerCase(), { + callsign: plane.callsign || '', + robloxUsername: plane.roblox_username, + model: plane.model || '', + }); + } + }); + } + } catch (err) { + console.error('[TrafficScraper] Failed to decode protobuf message:', err); + } + }); + + this.ws.on('close', () => { + this.currentProxyIndex++; + this.scheduleReconnect(); + }); + + this.ws.on('error', (err) => { + console.error(`[TrafficScraper] WebSocket error with proxy index ${this.currentProxyIndex % proxies.length}:`, err.message); + this.ws?.terminate(); + }); + } + + private scheduleReconnect() { + if (!this.reconnectInterval) { + this.reconnectInterval = setInterval(() => this.connect(), 10000); + } + } + + public getCallsignForUser(robloxUsername: string): PlaneData | null { + return this.traffic.get(robloxUsername.toLowerCase()) || null; + } +} + +export const trafficScraper = TrafficScraper.getInstance(); diff --git a/src/components/common/CallsignInput.tsx b/src/components/common/CallsignInput.tsx index 8eb0feae..a9ed8556 100644 --- a/src/components/common/CallsignInput.tsx +++ b/src/components/common/CallsignInput.tsx @@ -1,13 +1,15 @@ import React, { useState, useRef, useEffect } from 'react'; import { useData } from '../../hooks/data/useData'; import { ChevronDown } from 'lucide-react'; +import PrefilledIndicator from './PrefilledIndicator'; interface CallsignInputProps { value: string; - onChange: (value: string) => void; + onChange: (_newValue: string) => void; placeholder?: string; required?: boolean; maxLength?: number; + isPrefilled?: boolean; } export default function CallsignInput({ @@ -16,11 +18,11 @@ export default function CallsignInput({ placeholder = 'e.g. DLH123', required = false, maxLength = 16, + isPrefilled = false, }: CallsignInputProps) { const { airlines } = useData(); const [showSuggestions, setShowSuggestions] = useState(false); const [filteredAirlines, setFilteredAirlines] = useState(airlines); - const [dropdownSearch, setDropdownSearch] = useState(''); const [validationError, setValidationError] = useState(''); const [hasBlurred, setHasBlurred] = useState(false); const inputRef = useRef(null); @@ -28,7 +30,6 @@ export default function CallsignInput({ useEffect(() => { const mainSearchTerm = value.toUpperCase(); - const dropdownSearchTerm = dropdownSearch.toUpperCase(); let filtered = airlines; @@ -41,14 +42,6 @@ export default function CallsignInput({ ); } - if (dropdownSearch) { - filtered = filtered.filter( - (airline) => - airline.icao.toUpperCase().includes(dropdownSearchTerm) || - airline.callsign.toUpperCase().includes(dropdownSearchTerm) - ); - } - filtered.sort((a, b) => { const aIcaoMatch = a.icao.toUpperCase().startsWith(mainSearchTerm); const bIcaoMatch = b.icao.toUpperCase().startsWith(mainSearchTerm); @@ -67,7 +60,7 @@ export default function CallsignInput({ }); setFilteredAirlines(filtered); - }, [value, dropdownSearch, airlines]); + }, [value, airlines]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -127,13 +120,11 @@ export default function CallsignInput({ return (
0 + className={`relative bg-gray-800 border-2 transition-all duration-75 z-10 ${shouldShowError ? 'border-red-600' : 'border-blue-600' + } ${showSuggestions && filteredAirlines.length > 0 ? 'rounded-t-3xl rounded-b-none border-b-0' : 'rounded-3xl' - }`} + }`} > 0) ? 'pr-14' : 'pr-6'}`} maxLength={maxLength} /> - {filteredAirlines.length > 0 && ( - - )} +
+ {isPrefilled && } + {filteredAirlines.length > 0 && ( + + )} +
{shouldShowError && ( diff --git a/src/components/common/Dropdown.tsx b/src/components/common/Dropdown.tsx index 82e23d05..7f044060 100644 --- a/src/components/common/Dropdown.tsx +++ b/src/components/common/Dropdown.tsx @@ -8,6 +8,7 @@ import { } from 'react'; import { createPortal } from 'react-dom'; import { ChevronDown } from 'lucide-react'; +import PrefilledIndicator from './PrefilledIndicator'; import type { ReactNode } from 'react'; import type { DropdownOption } from '../../types/dropdown'; @@ -24,6 +25,7 @@ interface DropdownProps { className?: string; size?: 'xs' | 'sm' | 'md' | 'lg'; id?: string; + isPrefilled?: boolean; } const sizeClasses = { @@ -46,6 +48,7 @@ function Dropdown({ className = '', size = 'md', id, + isPrefilled = false, }: DropdownProps) { const [isOpen, setIsOpen] = useState(false); const [isMeasured, setIsMeasured] = useState(false); @@ -311,9 +314,8 @@ function Dropdown({ type="button" key={option.value} data-dropdown-selected={isSelected ? true : undefined} - className={`block w-full text-left px-3 py-2 rounded-xl text-sm hover:bg-blue-600 hover:text-white ${ - isSelected ? 'text-white font-medium' : 'text-gray-300' - }`} + className={`block w-full text-left px-3 py-2 rounded-xl text-sm hover:bg-blue-600 hover:text-white ${isSelected ? 'text-white font-medium' : 'text-gray-300' + }`} onClick={() => handleOptionClick(option.value)} > {renderOption ? renderOption(option) : option.label} @@ -333,21 +335,23 @@ function Dropdown({ onClick={toggleOpen} disabled={disabled} className={`flex items-center justify-between w-full bg-gray-800 border-2 border-blue-600 rounded-full text-left - ${ - disabled ? 'opacity-70 cursor-not-allowed' : 'hover:bg-gray-650' + ${disabled ? 'opacity-70 cursor-not-allowed' : 'hover:bg-gray-650' } ${sizeClasses[size]} ${className}`} > {displayValue} - - - +
+ + + + {isPrefilled && } +
diff --git a/src/components/common/PrefilledIndicator.tsx b/src/components/common/PrefilledIndicator.tsx new file mode 100644 index 00000000..779a2bb6 --- /dev/null +++ b/src/components/common/PrefilledIndicator.tsx @@ -0,0 +1,15 @@ +import { Sparkles } from 'lucide-react'; + +export default function PrefilledIndicator() { + return ( +
+ +
+
+ PREFILLED FROM PFATC +
+
+
+
+ ); +} diff --git a/src/components/dropdowns/AircraftDropdown.tsx b/src/components/dropdowns/AircraftDropdown.tsx index 46dcd5d9..cdbaf1ff 100644 --- a/src/components/dropdowns/AircraftDropdown.tsx +++ b/src/components/dropdowns/AircraftDropdown.tsx @@ -8,6 +8,7 @@ interface AircraftDropdownProps { disabled?: boolean; size?: 'xs' | 'sm' | 'md' | 'lg'; showFullName?: boolean; + isPrefilled?: boolean; } export default function AircraftDropdown({ @@ -16,6 +17,7 @@ export default function AircraftDropdown({ disabled = false, size = 'md', showFullName = true, + isPrefilled = false, }: AircraftDropdownProps) { const { aircrafts, loading } = useData(); @@ -56,6 +58,7 @@ export default function AircraftDropdown({ disabled={disabled || loading} getDisplayValue={getDisplayValue} size={size} + isPrefilled={isPrefilled} /> ); } diff --git a/src/pages/Submit.tsx b/src/pages/Submit.tsx index a3803d9f..c9cfbcc5 100644 --- a/src/pages/Submit.tsx +++ b/src/pages/Submit.tsx @@ -26,6 +26,7 @@ import { addFlight } from '../utils/fetch/flights'; import { useAuth } from '../hooks/auth/useAuth'; import { useSettings } from '../hooks/settings/useSettings'; import { fetchBackgrounds, fetchRoute } from '../utils/fetch/data'; +import { fetchPilotCallsign } from '../utils/fetch/pilot'; import type { Flight } from '../types/flight'; import AirportDropdown from '../components/dropdowns/AirportDropdown'; import Dropdown from '../components/common/Dropdown'; @@ -88,6 +89,8 @@ export default function Submit() { typeof createFlightsSocket > | null>(null); const [initialLoadComplete, setInitialLoadComplete] = useState(false); + const [isCallsignPrefilled, setIsCallsignPrefilled] = useState(false); + const [isAircraftPrefilled, setIsAircraftPrefilled] = useState(false); useEffect(() => { if (success && submittedFlight && !user) { @@ -148,6 +151,26 @@ export default function Submit() { .finally(() => setLoading(false)); }, [sessionId, initialLoadComplete]); + useEffect(() => { + if (!user || user.robloxUsername === null || !initialLoadComplete) return; + + fetchPilotCallsign() + .then((data) => { + if (data && data.callsign) { + setForm((f) => ({ + ...f, + callsign: data.callsign || f.callsign, + aircraft_type: data.model || f.aircraft_type, + })); + if (data.callsign) setIsCallsignPrefilled(true); + if (data.model) setIsAircraftPrefilled(true); + } + }) + .catch((err) => { + console.error('Error fetching pilot callsign:', err); + }); + }, [user, initialLoadComplete]); + useEffect(() => { const loadImages = async () => { try { @@ -247,6 +270,11 @@ export default function Submit() { const handleChange = (name: string) => (value: string) => { setForm((f) => ({ ...f, [name]: value })); + if (name === 'callsign') { + setIsCallsignPrefilled(false); + } else if (name === 'aircraft_type') { + setIsAircraftPrefilled(false); + } }; const handleSubmit = async (e: React.FormEvent) => { @@ -601,6 +629,7 @@ export default function Submit() {
diff --git a/src/utils/fetch/pilot.ts b/src/utils/fetch/pilot.ts index 0cb14575..393dc2b2 100644 --- a/src/utils/fetch/pilot.ts +++ b/src/utils/fetch/pilot.ts @@ -5,7 +5,24 @@ export async function fetchPilotProfile( ): Promise { try { const res = await fetch( - `${import.meta.env.VITE_SERVER_URL}/api/pilot/${username}` + `${import.meta.env.VITE_SERVER_URL}/api/pilot/${username}`, + { credentials: 'include' } + ); + if (res.ok) { + return await res.json(); + } else { + return null; + } + } catch { + return null; + } +} + +export async function fetchPilotCallsign() { + try { + const res = await fetch( + `${import.meta.env.VITE_SERVER_URL}/api/pilot/callsign/data`, + { credentials: 'include' } ); if (res.ok) { return await res.json();